ConceptsModifiers

Modifiers

Add-ons, options, and conditional fields that change a product variant at checkout.

This guide walks you through wiring modifier groups into a vibe-coded storefront end-to-end: defining the group as a merchant, attaching it to a product, rendering the selectors on the PDP, and reading the priced snapshot back off the cart line.

If you're looking for the full API reference, see:

What a modifier group is (and isn't)

A modifier group is a merchant-defined block of priced options attached to a product:

Toppings: pick 0 to 8, first 3 free

  • Olives, +5.00
  • Mushrooms, +5.00
  • Bacon, +7.00
  • Egg, +6.00 (sold out)
  • Cheese, +5.00 (default)

vs. a product customization field, which is arbitrary buyer input (text, photo, color pick) without intrinsic prices. Use modifier groups when picks are pre-priced and follow selection rules; use customization fields for "engrave this name" or "upload this photo".

Step 1: Create the group (admin)

Either via the dashboard at /(dashboard)/products/modifier-groups or through the SDK:

import { BrainerceClient } from 'brainerce';

const admin = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });

const group = await admin.createModifierGroup(storeId, {
  name: 'Toppings', // customer-facing
  internalName: 'Pizza toppings', // admin-only — disambiguates in long lists
  selectionType: 'MULTIPLE',
  minSelections: 0,
  maxSelections: 8,
  freeQuantity: 3,
  freeAllocationPolicy: 'EXPENSIVE_FREE', // best-for-customer: pricey ones go free
  required: false,
});

await admin.createModifier(storeId, group.id, {
  name: 'Olives',
  priceDelta: '5.00', // strings — never JSON Number
});
await admin.createModifier(storeId, group.id, {
  name: 'Bacon',
  priceDelta: '7.00',
});
// …add the rest…

Why decimals are strings. Money on the wire is always a decimal string ("5.00", "-2.00" for downsell modifiers). Storing them as JSON numbers introduces floating-point drift when you start summing them. The server uses Prisma.Decimal end-to-end and the SDK preserves the string at every boundary; only convert with parseFloat at display time.

Step 2: Attach to a product

await admin.attachModifierGroup(storeId, pizzaProductId, {
  modifierGroupId: group.id,
  position: 0,
});

That creates a default attach (variantId === null), so the group applies to every variant of the pizza by default.

Per-variant overrides (optional)

If the Large pizza should get 4 free toppings instead of 3, attach a second row keyed by variantId:

await admin.attachModifierGroup(storeId, pizzaProductId, {
  modifierGroupId: group.id,
  variantId: largeVariantId,
  freeQuantityOverride: 4,
});

To hide the group entirely on a specific variant (e.g., the kids' small pizza doesn't get toppings), use the maxOverride: 0 convention:

await admin.attachModifierGroup(storeId, pizzaProductId, {
  modifierGroupId: group.id,
  variantId: kidsSmallVariantId,
  maxOverride: 0, // 0 = hidden for this variant
});

The storefront skips groups where effectiveMax === 0; the cart-side validator silently skips them too.

Step 3: Fetch on the storefront

import { BrainerceClient } from 'brainerce';
import type { ModifierGroup } from 'brainerce';

const client = new BrainerceClient({
  connectionId: process.env.BRAINERCE_CONNECTION_ID, // vc_*
});

const product = await client.getProductBySlug('family-pizza');
const groups: ModifierGroup[] = product.modifierGroups ?? [];

The groups arrive already-resolved for the active variant. min, max, freeQuantity, required, freeAllocationPolicy, and defaultModifierIds are the effective values the server will validate against, so there is no need to merge group-level fields with attachment-level overrides client-side.

internalName is never present in storefront responses. If your client code is reading it, you're hitting an admin endpoint by mistake.

Step 4: Render the selectors

Drop in the reference component from the scaffolding template:

// src/components/products/modifier-group-selector.tsx
import {
  ModifierGroupSelector,
  buildInitialSelections,
  toModifierSelections,
  validateSelections,
} from '@/components/products/modifier-group-selector';

const [selections, setSelections] = useState<Record<string, string[]>>(() =>
  buildInitialSelections(groups)
);

return (
  <>
    {groups.map((group) => (
      <ModifierGroupSelector
        key={group.id}
        group={group}
        value={selections[group.id] ?? []}
        onChange={(next) => setSelections((prev) => ({ ...prev, [group.id]: next }))}
      />
    ))}
  </>
);

<ModifierGroupSelector> renders selectionType: 'SINGLE' as a radio group and 'MULTIPLE' as checkboxes. It honors defaultModifierIds and isDefault on first render, hides groups with max === 0, disables sold-out modifiers with a "Sold out" badge, and shows a running {used} of {N} free counter when freeQuantity > 0. The storefront author is encouraged to copy this file and tailor the markup / styling. The data contract is what's pinned; the markup is yours.

Step 5: Add to cart

const error = validateSelections(groups, selections); // optional client-side mirror
if (error) {
  showError(error);
  return;
}

await client.smartAddToCart({
  productId: product.id,
  variantId: selectedVariant.id,
  quantity: 1,
  selections: toModifierSelections(groups, selections),
});

The wire format is ModifierSelection[]:

[
  { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
  { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
];

modifierIds is in click-order. The server uses this for the SELECTION_ORDER free-allocation policy. Don't sort the array, because the order the customer clicked is meaningful.

Step 6: Read the priced snapshot

The cart line carries a per-modifier breakdown:

const cart = await client.getCart();
const line = cart.items.find((i) => i.productId === product.id)!;

// line.unitPrice       — final unit price including paid modifiers
// line.modifiersTotal  — decimal string of the paid (non-free) deltas
// line.modifiers       — CartItemModifierLine[] with snapshots:
//   { modifierId, name, priceDelta, freeApplied }

freeApplied: true means the modifier consumed a free slot, so render it with a "free" badge in the cart and order detail UI. After checkout the same shape lands on order.items[i], immutable to subsequent catalog edits (PRD §5.6.4).

Step 7: Handle validation failures

When the server rejects the payload, the SDK throws BrainerceError with a structured envelope on .details:

try {
  await client.smartAddToCart({ productId, quantity: 1, selections });
} catch (err) {
  // `.details` is the WHOLE response body, and that body carries its own
  // `details` block — so the issue list sits two hops down.
  const e = err as {
    statusCode?: number;
    details?: {
      code?: string;
      details?: { errors?: Array<{ code: string; message: string }> };
    };
  };
  if (e.statusCode === 400 && e.details?.code === 'MODIFIER_VALIDATION_FAILED') {
    for (const issue of e.details.details?.errors ?? []) {
      console.error(issue.code, issue.message);
    }
  }
}

The 12 stable codes you can switch on are listed in Rules & Reference "Modifier validation errors". Special-case MODIFIER_PRICE_FLOOR_VIOLATED: the server reports a generic message (internals never leak), so show a friendly "Cannot apply more discounts on this item" and let the customer remove a downsell.

Editing selections after add-to-cart (idempotent)

await client.updateCartItem(cart.id, line.id, {
  quantity: 1,
  selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
});

PATCH /cart/items/:id with a fresh selections array replaces the line's modifiers atomically: the server deletes existing CartItemModifier rows and recreates them inside the same transaction (PRD §7.2.3). Omit selections to leave them unchanged.

Multi-language (translations)

Both modifier group (name, description) and modifier (name, description) carry a translations JSON column. When the storefront fetches a product with setLocale('he') (or equivalent Accept-Language header), the server overlays the active locale on top of the base fields before sending the response, so your render code stays the same.

Merchants edit translations from the dashboard at /products/modifier-groups:

  • Per-row overlay: click the cell on the list page to translate one group (with one-click AI auto-translate).
  • Inside the group/modifier modal: LocaleSelector in the header swaps the form into translation mode; the "Translate with AI" button populates empty target-locale fields in one shot. Saving the modal persists group.translations and each modifier.translations.
  • Bulk: select rows on the list page and the toolbar offers "Translate to Hebrew/Arabic/…" which enqueues an AI translation job for every selected group and all their modifiers.

On the storefront you don't need to special-case anything:

client.setLocale('he'); // once at app boot
const product = await client.getProductBySlug(slug); // groups + modifiers come back translated

If you need to expose a language switcher per-call:

const productHe = await client.getProductBySlug(slug, { locale: 'he' });
const productEn = await client.getProductBySlug(slug, { locale: 'en' });

See Translations & i18n for the full storefront i18n flow and RTL handling.

Advanced features

  • Nested combos (depth ≤ 3): modifiers can carry referencedProductId. When picked, the storefront fetches that product's own modifier groups, collects nested selections, and passes them on add-to-cart via nestedByModifierId keyed by the parent modifier id. Server enforces depth.
  • Downsell modifiers: a negative priceDelta ("-2.00" for "no bread"). They never consume a free slot, can't be combined with nested combos, and trigger MODIFIER_PRICE_FLOOR_VIOLATED if they'd push unitPrice below 0.

Note: Scheduled availability, KDS station, course, and prep time exist at the data layer (ProductAvailability, Product.kdsStation etc.) but the dashboard UI for these was removed. Storefronts may read these fields if they were populated via API.

Common mistakes

MistakeSymptomFix
Treating priceDelta as a numberFloat drift on totalsAlways strings; parseFloat only at display time
Computing line total client-sideDiverges from server's free-allocationRender cart.items[i].unitPrice directly
Rendering a group with max: 0Variant-disabled group shows upSkip groups where effectiveMax === 0
Reading internalName on the storefrontAlways undefinedIt's admin-only, so switch to name
Sending modifiers instead of selectionsHTTP 400The request key is selections; modifiers is the response
Sorting modifierIds before sendingSELECTION_ORDER policy breaksPreserve click-order
Hardcoding the validation message in i18nNew backend codes show raw EnglishSwitch on errors[].code, keep message as the fallback

Where each piece lives in the repo

ConcernPath
Schemapackages/database/prisma/schema.prisma (models ModifierGroup, Modifier, ProductModifierGroup, CartItemModifier)
Brain (pure functions)apps/backend/src/modules/products/modifier-groups/{modifier-pricing,modifier-resolver,modifier-validation}.service.ts
Stateful wrapperapps/backend/src/modules/products/modifier-groups/modifier-groups.service.ts
Admin endpointsapps/backend/src/modules/products/modifier-groups/{modifier-groups,modifiers,product-attachments}.controller.ts
Cart wire-inapps/backend/src/modules/cart/cart.service.ts (priceCartLine + dedup hash + $transaction)
Order snapshotapps/backend/src/modules/checkout/checkout.service.ts (modifier breakdown into Order.items)
SDK methods + typespackages/sdk/src/{client,types}.ts
Dashboard list pageapps/frontend/src/app/(dashboard)/products/modifier-groups/page.tsx
MCP knowledgepackages/mcp-server/src/content/sections.ts (getModifierGroupsSection)