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:
- PRD:
docs/prd/modifier-groups.md - 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 /(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:
LocaleSelectorin the header swaps the form into translation mode; the "Translate with AI" button populates empty target-locale fields in one shot. Saving the modal persistsgroup.translationsand eachmodifier.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 translatedIf 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 vianestedByModifierIdkeyed 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 triggerMODIFIER_PRICE_FLOOR_VIOLATEDif they'd pushunitPricebelow0.
Note: Scheduled availability, KDS station, course, and prep time exist at the data layer (
ProductAvailability,Product.kdsStationetc.) but the dashboard UI for these was removed. Storefronts may read these fields if they were populated via API.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Treating priceDelta as a number | Float drift on totals | Always strings; parseFloat only at display time |
| Computing line total client-side | Diverges from server's free-allocation | Render cart.items[i].unitPrice directly |
Rendering a group with max: 0 | Variant-disabled group shows up | Skip groups where effectiveMax === 0 |
Reading internalName on the storefront | Always undefined | It's admin-only, so switch to name |
Sending modifiers instead of selections | HTTP 400 | The request key is selections; modifiers is the response |
Sorting modifierIds before sending | SELECTION_ORDER policy breaks | Preserve click-order |
| Hardcoding the validation message in i18n | New backend codes show raw English | Switch on errors[].code, keep message as the fallback |
Where each piece lives in the repo
| Concern | Path |
|---|---|
| Schema | packages/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 wrapper | apps/backend/src/modules/products/modifier-groups/modifier-groups.service.ts |
| Admin endpoints | apps/backend/src/modules/products/modifier-groups/{modifier-groups,modifiers,product-attachments}.controller.ts |
| Cart wire-in | apps/backend/src/modules/cart/cart.service.ts (priceCartLine + dedup hash + $transaction) |
| Order snapshot | apps/backend/src/modules/checkout/checkout.service.ts (modifier breakdown into Order.items) |
| SDK methods + types | packages/sdk/src/{client,types}.ts |
| Dashboard list page | apps/frontend/src/app/(dashboard)/products/modifier-groups/page.tsx |
| MCP knowledge | packages/mcp-server/src/content/sections.ts (getModifierGroupsSection) |