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 sales-channel 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:

  • PRD: the product requirements doc in the repository, docs/prd/modifier-groups.md (not part of this site)
  • SDK methods: Core Integration §2.9
  • Validation rules + error codes: Rules & Reference "Modifier validation errors"
  • Restaurant features: Optional Features "Restaurant / build-your-own products"

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 /products/modifier-groups (sidebar: Products, Modifiers) 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({
  salesChannelId: process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_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/ui/product/modifier-group-selector.tsx
import {
  ModifierGroupSelector,
  buildInitialSelections,
  toModifierSelections,
  validateSelections,
} from '@/ui/product/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.smartGetCart();
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 10 per-issue codes you can switch on are listed in Rules & Reference "Modifier validation errors". Special-case MODIFIER_PRICE_FLOOR_VIOLATED: it is a single entry of details.errors inside the same MODIFIER_VALIDATION_FAILED 400 (not the code of the whole response), with 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/{cartId}/items/{itemId} (updateCartItem()) 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. The product you add is level 1, so a combo can nest two more levels below it; the server refuses a 4th level with a 400 (nested structure exceeds maximum depth of 3).
  • Sold-out modifiers: a modifier with available: false is shown disabled and the cart rejects it with MODIFIER_UNAVAILABLE. Merchants flip it from the add-on availability board at /products/sold-out (it lists every active group with a toggle per modifier and refreshes every 30 seconds), or with updateModifier(storeId, groupId, modifierId, { available: false }). Marking one sold out takes effect on the storefront immediately.
  • Premium modifiers: excludeFromFree: true means the modifier never consumes a free slot, so customers always pay its priceDelta.
  • Downsell modifiers: a negative priceDelta ("-2.00" for "no bread"). They never consume a free slot, can't be combined with nested combos, and are refused with a MODIFIER_PRICE_FLOOR_VIOLATED issue (inside the MODIFIER_VALIDATION_FAILED 400) if they'd push unitPrice below 0.

Note: Scheduled availability has a dashboard UI: the Availability Hours column of the Products table (hidden by default, switch it on with Columns) sets the per-day time windows when a product can be bought (ProductAvailability, also reachable through the API and the admin MCP tools). KDS station, course, and prep time exist at the data layer (Product.kdsStation etc.) but have no dashboard UI. Storefronts may read these fields if they were populated via API.

Field reference and write-time rules

Admin routes live under /api/stores/{storeId}/modifier-groups (and its modifiers and product-attachment sub-routes). Defaults below are the stored defaults when a field is omitted.

Modifier group (createModifierGroup / updateModifierGroup)

FieldType / valuesDefaultNotes
namestring, requirednoneCustomer-facing
internalNamestringnoneAdmin only, stripped from storefront responses
descriptionstringnone
translations{ [locale]: { name?, description? } }noneSee Translations
selectionTypeSINGLE or MULTIPLEMULTIPLESINGLE needs maxSelections 1 (or unset) and minSelections 0 or 1
minSelectionsinteger >= 00
maxSelectionsinteger >= 0, or nullnull (unlimited)Must be >= minSelections
freeQuantityinteger >= 00How many picks are free
requiredbooleanfalsetrue needs minSelections >= 1
freeAllocationPolicyEXPENSIVE_FREE, CHEAPEST_FREE or SELECTION_ORDEREXPENSIVE_FREEWhich picks take the free slots
positioninteger0Default order hint; the per-attachment position always wins

Listing groups (listModifierGroups) takes page (>= 1), limit (1 to 500, default 20), search and status (active or archived).

Modifier (createModifier / updateModifier)

FieldType / valuesDefaultNotes
namestring, requirednone
priceDeltadecimal string, requirednoneMay be negative (a downsell); the line price can never go below 0
descriptionstringnone
translations{ [locale]: { name?, description? } }none
skustringnone
image, imageKeyobject { url, thumbnailUrl, alt }, stringnoneimageKey is derived from image.url when you omit it
positioninteger0
isDefaultbooleanfalsePre-ticked on first render
availablebooleantruefalse = sold out. toggleModifierAvailability sets just this field
excludeFromFreebooleanfalseNever takes a free slot
referencedProductIdstring (a product of this store)noneMakes the modifier a nested combo

Attachment (attachModifierGroup / updateAttachment): modifierGroupId (required on attach), variantId (omit for the default attach; not changeable on update), position, and the overrides minOverride, maxOverride, freeQuantityOverride, requiredOverride, freeAllocationPolicyOverride (each an integer, boolean or policy, or null to inherit the group value), plus defaultModifierIds (an empty list inherits, a non-empty list overrides).

Refusals on write. Each is a 400 whose code names the rule: INVALID_MIN_SELECTIONS, INVALID_FREE_QUANTITY, INVALID_MAX_SELECTIONS (max below min), INVALID_SINGLE_MAX, INVALID_SINGLE_MIN, INVALID_REQUIRED, INVALID_NEGATIVE_NESTED_COMBO (a modifier cannot have both a negative priceDelta and a referencedProductId), NESTED_COMBO_CYCLE (the reference would loop back to the product), NESTED_COMBO_TOO_DEEP (more than 3 levels of references) and NESTED_COMBO_UNSUPPORTED_TYPE (a KIT product cannot be a nested combo product). A missing group, modifier or product is a 404.

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