Inventory & Variants
Stock tracking, out-of-stock states, and product variants with per-variant pricing.
Inventory in this boilerplate is optional. A product with no numeric stock count is treated as untracked and always purchasable, so a quote-based catalog works out of the box without anyone touching stock numbers. When you do set counts, the storefront surfaces availability, the checkout validates against it, and orders decrement it — all from a small set of pure helpers shared by every backend.
Variants (size, color, and so on) layer on top: each variant can carry its own price, stock, and image, and the checkout resolves the selected variant server-side into its own line item.
Stock counts are an internal detail. The public Store API never exposes a number — it
collapses stock into a boolean inStock flag (or null when untracked). See
the public flag below.
Stock tracking model
A product's inventory lives in a single optional field, stock, on the catalog document.
The rule is consistent everywhere:
stock value | Meaning |
|---|---|
a number > 0 | In stock; that many units available |
0 | Out of stock |
undefined (or any non-number) | Inventory not tracked — always purchasable |
The Product type in src/types/product.ts documents this directly:
export interface Product {
// ...
/**
* Units available in inventory. `undefined` means inventory is NOT tracked
* for this product (always purchasable); `0` means out of stock.
*/
stock?: number;
}Because the field is optional, you never have to think about inventory for a catalog that sells via quotes only. Set a number when you want a product gated by stock; leave it off to keep the item permanently available.
Out-of-stock states in the storefront
The product detail page (src/app/[locale]/products/[id]/page.tsx) renders a
StockAvailability indicator driven entirely by the resolved stock number. It renders
nothing at all when stock is not a number, so untracked products show no availability
line:
function StockAvailability({ stock }: { stock?: number }) {
const { t } = useTranslations();
if (typeof stock !== "number") return null;
const { dotClass, textClass, label } =
stock === 0
? { /* red dot */ label: t("product.outOfStock") }
: stock <= 5
? { /* amber dot */ label: t("product.onlyNLeft", { n: stock }) }
: { /* green dot */ label: t("product.inStock") };
// ...
}So the three tracked states are:
0— red dot, "Out of stock"1–5— amber dot, "Only N left"> 5— green dot, "In stock"
The Add to cart button (src/components/product/AddToCartButton.tsx) also reacts to
stock: when product.stock === 0 it renders disabled with an out-of-stock label instead of
the usual add action.
When a variant is selected, the page swaps in the variant's own stock before rendering both the indicator and the button, so availability reflects the exact variant a shopper picked (see Variants).
The public inStock flag
The read-only Store API never returns raw counts. In
src/lib/api/store.ts, toPublicProduct maps the internal record to a stable wire shape
and reduces stock to a nullable boolean:
// `undefined` (or any non-number) means inventory is not tracked.
inStock: typeof stock === "number" ? stock > 0 : null,inStock | Meaning |
|---|---|
true | Tracked and has units |
false | Tracked and out of stock |
null | Not tracked (always purchasable) |
This keeps exact inventory levels private while still letting native apps and other frontends show an availability badge.
Product variants
A product sold in variations declares two optional catalog fields, both defined in
src/types/product.ts:
options— the option axes, e.g.[{ name: "Color", values: ["Black", "White"] }]variants— the purchasable combinations
Each ProductVariant can override price, carry its own stock, and supply its own image:
export interface ProductVariant {
/** Unique within the product, e.g. "black-l". */
id: string;
/** Display name, e.g. "Black / L". */
name: string;
/** Selected value per option name, e.g. { Color: "Black", Size: "L" }. */
options: Record<string, string>;
/** Overrides product.price when set. */
price?: number;
/**
* Per-variant stock. Same semantics as Product.stock:
* undefined = untracked, 0 = out of stock.
*/
stock?: number;
/** Optional variant-specific image; falls back to primaryImageURL. */
imageURL?: string;
}Products without a variants array behave exactly as before — none of the variant logic
runs for them.
How the storefront resolves a variant
On the product page, once a shopper has picked a value for every option axis, the page finds the matching variant and swaps in its price, stock, and image (falling back to the product-level values when a variant field is absent):
const displayPrice = selectedVariant?.price ?? product.price;
// Once a variant is resolved its stock applies (undefined = untracked);
// until then fall back to the product-level stock.
const displayStock = selectedVariant ? selectedVariant.stock : product.stock;
const displayImage = selectedVariant?.imageURL || product.primaryImageURL;Until the selection is complete, the Add to cart button is disabled with a
"Select options" label. The option axes come from the product's options field, or are
derived from the variants themselves when options is absent, so seeded documents still
render selectors.
Variant-aware checkout
Prices — and, for variants, identity — are never trusted from the client. The Stripe
checkout route (src/app/api/checkout/route.ts) re-fetches each product from Firestore and
resolves the line server-side with resolveCheckoutLine from src/lib/checkout-line.ts.
The checkout request item carries an optional variantId:
z.object({
id: z.string().min(1).max(128),
quantity: z.number().int().positive().max(10_000),
/** Selected variant, for products sold in variants. */
variantId: z.string().min(1).max(128).optional(),
})findVariant and resolveCheckoutLine
findVariant(variants, variantId) looks up a variant by id in the untrusted variants
array. resolveCheckoutLine(product, item) then applies these rules:
- If the item has a
variantId, the variant must exist — otherwise the line resolves to an error (Variant "…" of "…" was not found.). - Name becomes
"Product Name (Variant Name)"for a variant line. - Price is the variant's
pricewhen set, otherwise the product'sprice. A missing or invalid price is an error — those products can only go through the quote flow ("…" has no price — request a quote for it instead.). - Stock is the variant's own
stockwhen it tracks stock (typeof variant.stock === "number"); otherwise the product-levelstockstill applies. If the resolved stock is a number and is less than the requested quantity, the line errors (Not enough stock for "…": N available, M requested.).
if (item.variantId !== undefined) {
const variant = findVariant(product.variants, item.variantId);
if (!variant) {
return { ok: false, error: `Variant "${item.variantId}" of "${productName}" was not found.` };
}
name = `${productName} (${String(variant.name ?? item.variantId)})`;
price = variant.price ?? product.price;
// A variant that tracks its own stock overrides the product's;
// otherwise the product-level stock (if any) still applies.
if (typeof variant.stock === "number") stock = variant.stock;
}Each resolved line becomes its own Stripe line item, named "Product Name (Variant Name)",
with productId (and variantId when present) attached as metadata. Two different
variants of the same product are two separate line items — they are never merged.
Stripe checkout is optional. Without STRIPE_SECRET_KEY this endpoint responds 503 and
the store keeps working with the quote flow only. See
environment variables.
Stock decrement on order
When an order is created, stock is decremented best-effort: untracked stock, unknown
product ids, and missing variants are skipped silently, and a failure here must never fail an
already-created order. The math lives in two pure helpers in src/lib/inventory.ts so the
two decrement paths behave identically and can be unit-tested without a database.
The two paths that call them:
dbService.createOrderFromQuote (src/lib/firebase/dbService.ts) uses the client SDK and a
writeBatch to apply the grouped updates:
const groups = groupStockDecrements(
(quote.items ?? []).map((item) => ({
id: typeof item.id === "string" ? item.id : "",
quantity: Number(item.quantity),
variantId: typeof item.variantId === "string" ? item.variantId : undefined,
}))
);
const batch = writeBatch(db);
for (const [productId, group] of Array.from(groups.entries())) {
const productSnap = await getDoc(doc(db, "products", productId));
if (!productSnap.exists()) continue;
const update = computeStockUpdate(productSnap.data() ?? {}, group);
if (update) batch.update(productRef, { ...update });
}POST /api/webhooks/stripe (src/app/api/webhooks/stripe/route.ts) mirrors the same
semantics with the Admin SDK after a payment succeeds:
const groups = groupStockDecrements(itemRefs);
const batch = adminDb.batch();
for (const [id, group] of Array.from(groups.entries())) {
const snapshot = await adminDb.collection("products").doc(id).get();
if (!snapshot.exists) continue;
const update = computeStockUpdate(snapshot.data() ?? {}, group);
if (update) batch.update(productRef, update);
}The Prisma and Supabase backends (src/lib/commerce/server/) apply the same best-effort
decrement in their own createFromQuote.
groupStockDecrements
groupStockDecrements(lines) groups purchased lines by product id so several lines touching
the same product document produce a single update. This fixes a last-write-wins bug when
two variants/sizes of one product appear as separate lines. It returns a
Map<string, ProductStockGroup>:
export interface ProductStockGroup {
/** Quantity to take off product-level stock. */
productQuantity: number;
/** Quantity to take off each variant's own stock, keyed by variant id. */
variantQuantities: Map<string, number>;
}A line with a non-empty variantId adds to variantQuantities; otherwise it adds to
productQuantity. Non-positive or non-finite quantities and empty ids are dropped.
computeStockUpdate
computeStockUpdate(product, group) takes the product's current data and its grouped
quantities and returns the exact fields to write — or null when nothing changes:
export interface StockUpdate {
stock?: number;
variants?: unknown[];
}The logic:
- For each variant quantity, if that variant tracks its own stock
(
Number.isFinite(stock)), the variant's entry in thevariantsarray is decremented, clamped at0(Math.max(0, stock - quantity)). - A variant that does not track its own stock (or no longer exists) falls back to the
product-level
stock— matching what checkout validated against — so product-level inventory actually depletes for those variant sales. - The product-level
stockis only written when the accumulated decrement is positive and the product actually has a numericstock, again clamped at0.
Untracked levels are therefore left untouched, and the update never introduces a stock
field on a product that did not already have one.