Product Variants in Next.js Without a Commerce Backend
Size and colour variants render fine from a typed file — until stock has to be authoritative. The option matrix, the URL-driven picker, the canonical, and where it breaks.
You can ship size, colour and material variants in Next.js with no commerce backend at all, as long as three things are true: the option matrix is known at build time, prices do not vary per customer, and stock does not have to be authoritative. Model variants as data, keep the selected variant in the URL, and let a hosted checkout own the money. This post is the pattern, and the line where it stops working.
This is the variant problem specifically — one product, many buyable combinations. It is not the filtering problem, which narrows a collection down to fewer products. Those two get confused because both end up in searchParams; they have different data models and different SEO consequences, and this post covers the second one.
Options are not variants
Getting this distinction right at the data layer prevents most of the bugs later.
| Term | What it is | Example |
|---|---|---|
| Option | An axis of choice | Size, Colour |
| Option value | One choice on that axis | M, Charcoal |
| Variant | One buyable combination, with its own identity | M / Charcoal, SKU TEE-M-CHR |
A variant — not a product — is what has a SKU, a price, a stock state, and sometimes its own image. A cart line references a variant. An order references a variant. If you find yourself putting price on the product and "colour" on the cart line, you have modelled it wrong and it will bite you at checkout.
Here is the whole model:
// src/data/products.ts
export interface ProductOption {
/** URL-safe axis name — becomes a query param. */
name: "size" | "color" | "material";
label: string;
values: { value: string; label: string; swatch?: string }[];
}
export interface Variant {
/** Stable identity. This is what the cart and the order reference. */
sku: string;
/** One entry per option, in the same order as `options`. */
selections: Record<string, string>;
priceCents: number;
compareAtCents?: number;
available: boolean;
/** Optional per-variant image; falls back to the product's. */
image?: string;
}
export interface Product {
slug: string;
name: string;
options: ProductOption[];
variants: Variant[];
images: string[];
}
Two rules that are worth enforcing in a test rather than in code review:
- Every variant's
selectionshas exactly one value per option, and every value exists in that option'svalueslist. - No two variants share the same
selections. A duplicated combination means the picker becomes non-deterministic — it will resolve to whichever one is first in the array, forever, and nobody will notice until a price is wrong.
// src/data/__tests__/products.test.ts
it("variant selections are complete and unique", () => {
for (const product of products) {
const seen = new Set<string>();
for (const v of product.variants) {
const key = product.options
.map((o) => `${o.name}:${v.selections[o.name]}`)
.join("|");
expect(key.includes("undefined"), `${v.sku} misses an option`).toBe(false);
expect(seen.has(key), `${v.sku} duplicates ${key}`).toBe(false);
seen.add(key);
}
}
});
The catalogue is the source of truth and a typo in it is a broken product page. Make the build fail, not the customer.
Put the selected variant in the URL
/products/merino-tee?size=m&color=charcoal is the right shape. The reasoning is the same as for filters — shareable, bookmarkable, back-button-correct, server-renderable — with one addition specific to variants: support can ask a customer for the URL and see exactly the thing they were looking at, price included.
The page reads the selection on the server and resolves a variant. No client state is involved in deciding what to render:
// src/app/products/[slug]/page.tsx
export default async function ProductPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const product = getProduct(slug);
if (!product) notFound();
const selection = parseSelection(product, await searchParams);
const variant = resolveVariant(product, selection);
return (
<ProductDetail
product={product}
selection={selection}
variant={variant}
image={variant?.image ?? product.images[0]}
/>
);
}
parseSelection is where the untrusted input gets cleaned. Anything not in the option's value list is discarded rather than passed through — an unknown value must produce the default variant, never an empty page:
export function parseSelection(
product: Product,
params: Record<string, string | string[] | undefined>,
): Record<string, string> {
const selection: Record<string, string> = {};
for (const option of product.options) {
const raw = params[option.name];
// A repeated param (?size=m&size=l) arrives as an array. Take the first
// rather than joining it into a value that matches nothing.
const value = Array.isArray(raw) ? raw[0] : raw;
const valid = option.values.some((v) => v.value === value);
selection[option.name] = valid
? (value as string)
: defaultValueFor(product, option);
}
return selection;
}
export function resolveVariant(product: Product, selection: Record<string, string>) {
return product.variants.find((v) =>
product.options.every((o) => v.selections[o.name] === selection[o.name]),
);
}
defaultValueFor should return the first value that appears in an available variant, not simply values[0]. Otherwise a product whose small is sold out opens on a disabled buy button, which reads as a broken page rather than a sold-out size.
The picker: links, not buttons
This is the part most implementations get wrong, and the fix is smaller than the bug.
A variant picker built from onClick handlers is invisible to crawlers, unusable before hydration, and cannot be middle-clicked or opened in a new tab. Each option value is a destination. Render it as a link:
// src/components/molecules/OptionPicker.tsx — a Server Component
import Link from "next/link";
export function OptionPicker({ product, option, selection }: Props) {
return (
<fieldset>
<legend className="text-sm font-medium">{option.label}</legend>
<div className="mt-2 flex flex-wrap gap-2">
{option.values.map((v) => {
const next = { ...selection, [option.name]: v.value };
const variant = resolveVariant(product, next);
const selected = selection[option.name] === v.value;
return (
<Link
key={v.value}
href={`?${new URLSearchParams(next)}`}
scroll={false}
replace
aria-current={selected ? "true" : undefined}
aria-disabled={!variant?.available || undefined}
className={pickerClass(selected, variant?.available)}
>
{v.label}
{!variant?.available && <span className="sr-only"> (sold out)</span>}
</Link>
);
})}
</div>
</fieldset>
);
}
Four details in there earn their place:
replacekeeps the back button meaningful. Without it, a shopper who tried four colours has to press back four times to leave the product.scroll={false}stops the page jumping to the top when a swatch below the fold is clicked.- The combination is resolved per value, so the picker can mark
M / Charcoalsold out whileM / Sandstays buyable. Rendering availability from the option rather than the combination is the classic variant bug: it tells the shopper charcoal is available and then refuses to sell it in their size. aria-disabledrather than removing the link. Hiding sold-out combinations is worse for shoppers, who then cannot tell whether the combination exists at all.
Zero client JavaScript so far. The page works before hydration and a crawler sees a real link per combination.
The crawl consequence, and the canonical that fixes it
Three options with four values each is 64 URLs for one product. Left alone, that is 63 near-duplicate pages competing with each other, and it is the same failure mode that leaves large catalogues sitting in Search Console's "Discovered — currently not indexed."
Point every variant URL at the bare product URL:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = getProduct(slug);
if (!product) return {};
return {
title: product.name,
// Note: no searchParams. Every variant URL canonicalises to the product.
alternates: { canonical: `/products/${product.slug}` },
};
}
generateMetadata does not receive searchParams, which is convenient here — the canonical is structurally incapable of leaking a variant. Combine that with a sitemap that lists only bare product URLs, and the 64 combinations consolidate into one indexable page carrying all its signals.
The exception is a variant that is genuinely its own product in search terms — where people search "merino tee charcoal" as a distinct intent, in volume. Then give it a real route (/products/merino-tee-charcoal) with its own copy and canonical, and accept that you now maintain it as a product. Do this deliberately, for a handful, never programmatically for all 64.
For the structured data, expose the variants as multiple offers on the one Product, each with its own SKU, price and availability. The Product schema post covers the shape and the manual-action traps; the variant-specific rule is that an offer's availability must match what the page actually says, because that is the mismatch that gets flagged.
Add to cart: the variant is the line item
The only client-side state in this design is the cart, and its line items reference SKUs — never a product plus a bag of loose option values:
// src/lib/actions/cart.ts
"use server";
export async function addToCart(formData: FormData) {
const sku = String(formData.get("sku") ?? "");
const variant = findVariantBySku(sku);
// Re-validate on the server. The form posted a string; a hidden field is
// not a fact, and price must come from the catalogue, never the request.
if (!variant?.available) return { error: "That option is no longer available." };
await addLine({ sku: variant.sku, quantity: 1, priceCents: variant.priceCents });
revalidatePath("/cart");
return { ok: true };
}
Two non-negotiables:
- Price comes from the server's catalogue lookup, keyed by SKU. If the price arrives in the form body, a shopper can edit it. This is the single most common vulnerability in hand-rolled storefronts.
- Availability is re-checked server-side, because the page may have been rendered before a rebuild.
Where the cart itself should live — a cookie, a database row, or React Context — is its own decision, and we worked through the three architectures in the shopping cart post.
Where this pattern stops working
Be honest about the ceiling. A build-time variant matrix is the right call until one of these becomes true:
| Signal | Why the static model breaks | What you need |
|---|---|---|
| Stock must be authoritative | Two shoppers race for the last unit; both succeed | A database with a real transaction |
| Prices change without a deploy | Sales, currencies, per-customer pricing | A commerce backend or a pricing service |
| Someone non-technical edits products | Every change is a PR | A CMS or commerce admin |
| The matrix is large or sparse | 500 SKUs in a TypeScript file is unmaintainable | Generate from a source of record |
| Options depend on each other | Frame size restricts wheel size restricts gearing | A configurator with real constraint logic |
The first row is the one that matters most. Overselling is a customer-service problem, not a bug you can apologise your way out of. If you cannot afford to oversell, you cannot afford a static stock flag — and the honest fix is a backend, or Shopify. We compare that decision properly in Shopify vs a Next.js storefront.
A pragmatic middle ground: keep the catalogue static and fetch only the availability flags at request time from a small endpoint. You get static rendering for the 95% that never changes and truth for the 5% that does.
Mistakes and how they show up
| Mistake | What the shopper sees | Fix |
|---|---|---|
| Availability computed per option, not per combination | "Add to cart" fails after choosing a size | Resolve the full combination for every swatch |
Variant state in useState | Cannot share or bookmark a colour; back button exits | Selection in searchParams |
| Picker built from buttons | Nothing works before hydration; no crawlable links | <Link> per option value |
| No canonical on variant URLs | 64 thin duplicates per product | Canonical to the bare product URL |
| Price submitted from the form | A shopper can pay what they choose | Look price up server-side by SKU |
Defaulting to values[0] | Product opens sold out | Default to the first available combination |
| Sold-out values hidden | Shoppers cannot tell the combination exists | Show, mark aria-disabled, say "sold out" |
Frequently asked questions
Do I need a database for product variants? Not for rendering them. You need one when stock must be authoritative, when prices change without a deploy, or when a non-developer edits the catalogue. A fixed matrix of SKUs and prices renders perfectly well from a typed file, and a hosted checkout can take the money.
Should each variant get its own URL path? Usually no — use query parameters and canonicalise to the bare product URL. Give a variant its own path only when it has genuine independent search demand and its own copy, and only for a handful you maintain by hand. Generating a route per combination creates thin, competing pages.
How do I handle combinations that do not exist? Render the option value, resolve it against the full matrix, and mark it unavailable rather than removing it. Removing values makes the picker jump around as the shopper changes their mind, and it hides whether the combination exists at all. Keep the buy button disabled while the resolved variant is missing or unavailable.
How many variants is too many for a static catalogue? The limit is maintainability, not performance — a few hundred SKUs is fine in a build-time file and adds nothing to the page. The point to move is when a human is hand-editing a matrix, or when the data has a real source of record elsewhere and your file is a copy of it. A copy of the truth drifts from the truth.
Can I use this with a headless commerce backend later?
Yes, and that is the usual path. Keep resolveVariant, parseSelection and the picker as pure functions over the types above, and swapping getProduct from a file read to an API call is a one-file change. Design the seam now and the migration is boring.
Starting from a storefront that already does this
Wiring variants from scratch is a day or two once you have decided the model. Our shop templates ship with the picker, the detail page and the cart already built.
ASoc Mode has a product detail page with size and colour selection, new-arrivals and top-selling grids, and a working cart with an order summary. ASoc Vogue is a fashion-and-lifestyle store with colour swatches, star ratings, sale badges and a slide-out cart drawer across a twelve-category menu. ASoc Linen is a minimalist capsule store — knitwear, dresses, outerwear — with shop-by-category browsing and an editorial journal.
Browse the full set of Next.js shop templates, or the Tailwind shop templates if you would rather bring your own framework.
