Acme Store Docs
Features

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 valueMeaning
a number > 0In stock; that many units available
0Out of stock
undefined (or any non-number)Inventory not tracked — always purchasable

The Product type in src/types/product.ts documents this directly:

src/types/product.ts
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:

src/app/[locale]/products/[id]/page.tsx
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"
  • 15 — 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:

src/lib/api/store.ts
// `undefined` (or any non-number) means inventory is not tracked.
inStock: typeof stock === "number" ? stock > 0 : null,
inStockMeaning
trueTracked and has units
falseTracked and out of stock
nullNot 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:

src/types/product.ts
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):

src/app/[locale]/products/[id]/page.tsx
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:

src/app/api/checkout/route.ts
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 price when set, otherwise the product's price. 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 stock when it tracks stock (typeof variant.stock === "number"); otherwise the product-level stock still 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.).
src/lib/checkout-line.ts
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:

src/lib/firebase/dbService.ts
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:

src/app/api/webhooks/stripe/route.ts
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>:

src/lib/inventory.ts
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:

src/lib/inventory.ts
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 the variants array is decremented, clamped at 0 (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 stock is only written when the accumulated decrement is positive and the product actually has a numeric stock, again clamped at 0.

Untracked levels are therefore left untouched, and the update never introduces a stock field on a product that did not already have one.

On this page