Skip to main content
ASoc
Tutorial

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.

The ASoc Team12 min read

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.

TermWhat it isExample
OptionAn axis of choiceSize, Colour
Option valueOne choice on that axisM, Charcoal
VariantOne buyable combination, with its own identityM / 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 selections has exactly one value per option, and every value exists in that option's values list.
  • 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.

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:

  • replace keeps 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 / Charcoal sold out while M / Sand stays 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-disabled rather 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:

SignalWhy the static model breaksWhat you need
Stock must be authoritativeTwo shoppers race for the last unit; both succeedA database with a real transaction
Prices change without a deploySales, currencies, per-customer pricingA commerce backend or a pricing service
Someone non-technical edits productsEvery change is a PRA CMS or commerce admin
The matrix is large or sparse500 SKUs in a TypeScript file is unmaintainableGenerate from a source of record
Options depend on each otherFrame size restricts wheel size restricts gearingA 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

MistakeWhat the shopper seesFix
Availability computed per option, not per combination"Add to cart" fails after choosing a sizeResolve the full combination for every swatch
Variant state in useStateCannot share or bookmark a colour; back button exitsSelection in searchParams
Picker built from buttonsNothing works before hydration; no crawlable links<Link> per option value
No canonical on variant URLs64 thin duplicates per productCanonical to the bare product URL
Price submitted from the formA shopper can pay what they chooseLook price up server-side by SKU
Defaulting to values[0]Product opens sold outDefault to the first available combination
Sold-out values hiddenShoppers cannot tell the combination existsShow, 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.

Keep reading

Tutorial8 min read

Next.js Redirects: Three APIs, and Why We Use Two of Them

Thirteen redirects, eight files, zero next.config.ts entries. Why every redirect() call here depends on auth state config-based redirects cannot see.

Read more
Tutorial9 min read

Next.js Rewrites: Three Phases, and the Four We Turned Down

318 pages and zero rewrites, with the compiled manifest to prove it. What each phase beats, why our seven hub pages stayed files, and the 308 redirect nobody configured.

Read more
Tutorial9 min read

Next.js Route Groups: Why This 24-Route App Uses Zero

Route groups hide a folder from the URL. Every one of this app's 24 top-level folders needs to be in the URL — which is the exact case they're not for.

Read more