Skip to main content
ASoc
Tutorial

Building a Next.js Storefront: The Four Parts, From Production Code

A Next.js storefront is a static catalogue, a hosted checkout, a signed webhook and a fulfilment check. The code, the Lighthouse numbers, and three defects we shipped.

The ASoc Team11 min read

A Next.js storefront is a statically rendered catalogue (product pages generated at build time), a hosted checkout, a signed webhook that grants access after payment, and a download or fulfilment route that re-checks ownership on every request. Everything else is presentation. This page walks that four-part shape using the production code from the storefront you are reading.

The ASoc marketplace is itself a Next.js 16 storefront: 111 products, each with its own statically generated page, sold through LemonSqueezy and fulfilled from a private Supabase bucket. It sells digital goods, so some parts differ from a physical-goods shop, and where they do this page says so. What follows is the architecture as it exists in src/, the numbers we measured on it, and the defects that cost us time.

The four parts, and where they live

PartJobFile in this repoRendering
CatalogueProducts, editions, prices, changelogsrc/data/catalog.tsBuild time
Product pageOne URL per product, with metadata and JSON-LDsrc/app/templates/[slug]/page.tsxStatic (SSG)
CheckoutLogin-gated hosted checkout linksrc/components/molecules/BuyButton.tsxClient island
WebhookVerify signature, record the order, grant accesssrc/app/api/webhooks/lemonsqueezy/route.tsNode route handler
EntitlementsPure function: does this buyer own this target?src/lib/entitlements.tsAnywhere
FulfilmentAuthorise and serve the filesrc/app/api/download/route.tsNode route handler

Two of those rows are "pure and boring" on purpose. The catalogue is a typed array with no JSX and the entitlement check is 75 lines with no I/O. Everything that touches money or a network is a thin shell around a function you can unit test.

Part 1: the catalogue is data, and the page is generated from it

The product page does not fetch anything at request time. It asks the catalogue which slugs exist and lets Next.js render each one at build time:

// src/app/templates/[slug]/page.tsx
type Params = { slug: string };

export function generateStaticParams(): Params[] {
  return catalog.map((p) => ({ slug: p.slug }));
}

One entry in catalog.ts therefore produces the page, its <title>, its Product JSON-LD, its card in the grid, its sitemap row and its changelog entry. There is no CMS and no product API to keep warm. For a catalogue that changes when you ship a release, not when a customer clicks, this is the cheapest storefront architecture there is.

It stops being right when stock or price changes between deploys. A shop with live inventory needs either on-demand revalidation or a data source the page reads at request time. The rule of thumb we use: if the thing on the page can be wrong for the length of a deploy, build it statically; if it cannot, it belongs behind a transaction and a real backend.

Images are part of the architecture. A storefront is mostly pictures. Our cover images are 2120px JPEGs because they double as og:image, but a card paints them at 530px wide. src/lib/imageVariants.ts points every rendered <img> at a pre-built WebP instead (1060px for cards, 1600px for the detail frame), so there is no request-time optimiser to pay for. The comment in that file puts the saving at roughly 255 KiB delivered for 33 KiB worth of pixels.

We did not build a checkout. BuyButton.tsx asks a server action for a signed checkout URL, because the buyer's id and email must come from the server session and not from the browser, then renders a plain anchor. If the checkout overlay script fails to load, the anchor still navigates to the hosted page. That fallback is deliberate: the button degrades to a link, not to nothing.

The part that bit us is not in the button. LemonSqueezy gives every variant two identifiers, and they are not interchangeable. From src/lib/lemonsqueezy/variants.ts:

/**
 * A LemonSqueezy variant has TWO identifiers, and they are not interchangeable:
 *
 *   - the **numeric id** ... which is what webhook payloads carry at
 *     `data.attributes.first_order_item.variant_id`; and
 *   - the **UUID** ... which is what the hosted `/checkout/buy/<id>` path
 *     accepts — the numeric id 404s there.
 *
 * ... put the UUID in `LS_VARIANT_ID_*` and checkout works while every webhook
 * falls through `tierForVariantId` to `202 ignored` — the buyer is charged and
 * granted nothing, with no error anywhere.
 */

The failure is silent and it fails in the worst direction: the customer pays and receives nothing. The fix that shipped is checkoutConfiguredForTier, which refuses to show a Buy button unless the UUID, the numeric id and the store subdomain are all set. A tier that can take money but cannot honour the order renders disabled. Fail closed.

The same idea applies to any provider. Whatever identifier your checkout accepts is probably not the identifier your webhook returns, so build the mapping once, in one file, and gate the UI on both halves being configured. For the full hop-by-hop version see selling a digital product from Next.js with LemonSqueezy and ecommerce process flow.

Part 3: the webhook is where a storefront earns trust

The browser returning from checkout proves nothing. The only trustworthy signal that an order is paid is the provider's signed webhook, so this is the route that grants access. Three rules, all visible in the 69-line handler in src/app/api/webhooks/lemonsqueezy/route.ts.

Read the raw body, not parsed JSON. The signature is an HMAC over the exact bytes the provider sent, and re-serialising a parsed object can differ byte for byte.

// src/app/api/webhooks/lemonsqueezy/route.ts
export async function POST(req: Request) {
  const raw = await req.text();
  const signature = req.headers.get("X-Signature");
  // ...
  const result = await processWebhook(raw, signature, { db, secret, storeId, ... });
  return new Response(result.body, { status: result.status });
}

Compare signatures in constant time, and never let a malformed header throw. src/lib/lemonsqueezy/signature.ts is 25 lines:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  if (!signatureHeader) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signatureHeader, "hex");
  return a.length === b.length && b.length > 0 && timingSafeEqual(a, b);
}

The length check carries real weight. timingSafeEqual throws on a length mismatch, and Buffer.from(str, "hex") never throws; it silently truncates invalid hex. Without the explicit check, a malformed header becomes either a 500 or a wrong answer.

Do slow work after responding. The welcome email goes through after() from next/server, not a floating promise. On Vercel, a promise you do not await has no guaranteed lifetime once the response is sent, so the invocation can freeze before the email goes out. The handler comment records why we chose after(): it is backed by waitUntil(), which keeps the invocation alive without delaying the response.

Part 4: ownership is a pure function, checked on every download

Fulfilment is where a storefront for digital goods differs most from one for physical goods. There is no shipping label; there is a file, and the question is whether this person may have it. Ours is the whole of the decision:

// src/lib/entitlements.ts
export function slotCovers(slot: Slot, target: DownloadTarget): boolean {
  if (slot.status !== "active") return false;
  switch (slot.kind) {
    case "all_access":
      return true;
    case "all_templates":
      return target.framework !== "backend";
    case "template_single":
      return (
        slot.productSlug === target.productSlug &&
        target.framework !== "backend"
      );
  }
}

export function authorizeDownload(slots: Slot[], target: DownloadTarget): boolean {
  return slots.some((s) => slotCovers(s, target));
}

Because it is pure, the tier rules are unit tests, not integration tests. And because /api/download calls it on every request, the UI can be sloppy without being unsafe. Components such as DownloadMenu read ownership through src/lib/useOwnedProducts.ts to decide what to show, but that is a rendering convenience. Hiding a button was never access control.

Part 5: the SEO surface of a 111-page storefront

A storefront's pages are only useful if they are crawled, and this is the area where our own site lost the most time. We hit three defects worth naming.

Defect we hitWhat it looked likeThe fix that shipped
Related products picked "newest" or "random"The tail of the catalogue was orphaned; 38 pages sat in Search Console's "Discovered - currently not indexed"RelatedProducts became a wrapping sliding window over catalogue order, so every product gets exactly 6 inbound sibling links
sitemap.ts stamped lastModified with the build clockGoogle was told 100+ URLs changed on every deploy, a checkable false claimDeclared dates only: the newer of a product's changelog and a STOREFRONT_COPY_REVISED constant; npm test asserts every date is midnight UTC
Titles built from the product nameAn invented brand nobody searches for; the catalogue tier held 0.7% of site impressionssrc/lib/productSeo.ts builds every string from seoLabel, the phrase describing what the product is, keyword first

The sliding window is worth copying if you have any long list of similar pages. This is the core of src/components/organisms/RelatedProducts.tsx:

const RAIL_SIZE = 6;

function siblings(product: TemplateProduct): TemplateProduct[] {
  const pool = catalog.filter(
    (p) => p.category === product.category && p.status === "available",
  );
  const start = pool.findIndex((p) => p.slug === product.slug);
  const from = start === -1 ? -1 : start;
  return Array.from({ length: Math.min(RAIL_SIZE, pool.length) }, (_, i) =>
    pool.length ? pool[(from + 1 + i) % pool.length] : undefined,
  ).filter(
    (p): p is TemplateProduct => p !== undefined && p.slug !== product.slug,
  );
}

The % pool.length is the entire idea. A "6 newest" rail gives a handful of products all the links; a window that wraps gives every product the same number. Catalogue order is the stable key, so re-releasing a product does not reshuffle the link graph between deploys.

What we measured

These are the figures from our own Lighthouse pass on 2026-08-08 (documented in docs/superpowers/LIGHTHOUSE.md), run against a local production build:

PageDesktop perfMobile perfA11ySEO
Home10089–91100100
/templates (grid of 111)10092100100
A product page10094100100
/pricing10099100100

The mobile gap is the useful finding. We blocked every image and every RSC prefetch and home's LCP moved only from 3786 ms to 3740 ms. The remaining cost is React hydration under Lighthouse's 4× CPU throttle, and the largest chunk is react-dom itself at 221 KiB raw and 70 KiB gzipped. The storefront ships 26 "use client" files out of 92 component files, and those are the preview modal, wishlist, owner download menu and forms. Getting mobile to 100 would mean deleting interactivity buyers use, which is a product decision rather than a performance fix, so we did not do it.

The takeaway for your own build: keep client islands small and leaf-level. Every organism except the header is a Server Component, and so are most molecules, so the copy and layout of a section cost nothing in JavaScript.

Troubleshooting

SymptomLikely causeFix
Buyer charged, no access grantedVariant UUID configured where the numeric id belongs, so the webhook falls through to 202 ignoredConfigure both ids; gate the Buy button on both (checkoutConfiguredForTier)
Webhook returns 401 for valid eventsBody parsed with req.json() before verifyingRead req.text() first and verify the HMAC over those bytes
Webhook throws on a garbage headertimingSafeEqual called on unequal buffer lengthsCompare decoded lengths first and return false
Welcome email sometimes never arrivesFire-and-forget promise frozen after the responseSchedule it with after()
Product pages "Discovered - currently not indexed"Pages with almost no inbound linksA wrapping sliding-window rail plus a category hub above it
Cards slow the grid on mobileFull-size cover JPEGs painted at card sizePre-built WebP variants; set priority on the first card only
Sitemap dates change every deploylastModified: new Date()Declared dates from changelogs and a revision constant

Frequently asked questions

Should a Next.js storefront be static or dynamic? Static wherever a page can be wrong for the length of a deploy, which covers product copy, images and pricing tiers for most catalogues. Make it dynamic, or revalidate on demand, for anything that must be true now: stock, per-customer prices, order status.

Do I need my own backend to sell with Next.js? For a few fixed-price items, a static catalogue with a hosted checkout is enough. You need a database the moment you must grant access after payment, because the webhook has to write something somewhere. Our schema is small: orders, slots, a download audit and RLS policies that let a user read only their own rows.

Should I build my own checkout? No. A hosted checkout is PCI-scoped and tested against payment methods you will never think of. Spend the time on the webhook, which is the part you cannot outsource. If you are weighing this against a hosted platform, Shopify vs a Next.js storefront prices that decision.

How do I add structured data to product pages? Emit Product JSON-LD from the same catalogue entry that builds the page, so the markup cannot drift from what is on screen. The walkthrough is Product schema JSON-LD in Next.js.

Start from a finished storefront

Everything above, from the static catalogue to the signed webhook, is a decision you can make once or inherit. If the storefront you need is a marketing front for a product rather than a catalogue, start from a finished layout: ASoc Zenith is a growth-marketing agency landing page, ASoc Aegis a risk-management landing page, and ASoc Ally an AI support chatbot landing page, each a typed, statically rendered Next.js project.

Browse the full set of Next.js landing page templates, or the Tailwind landing page templates if you would rather bring your own framework. If you are structuring the components themselves, atomic design for Next.js components is the layout this storefront uses, and the server-component shopping cart covers the part this page deliberately left out.

Keep reading

Tutorial11 min read

Storefront Search Without a Search Service

111 products, no search index. The predicate, the rule that stops results looking broken, and the bundle trade we took on one page only.

Read more
Tutorial8 min read

Next.js Testing: 305 Tests, Zero Rendered Components

31 test files, zero jsdom, zero @testing-library — every assertion is a data invariant or a security boundary. What that catches, and what it can't.

Read more
Tutorial10 min read

Next.js Trailing Slash: The Config Isn't Your Problem, the Env Var Is

344 prerendered routes, zero trailing slashes, and no trailingSlash config at all. The bug that did reach production came from one env var, and the one-line fix it now has.

Read more