MODRACXKENNETH D'SILVA

← Archive & Insights

JAMstack E-Commerce: Static Generation & Dynamic Hydration

A lighting retailer's catalogue tripled and their build went from eleven minutes to four hours and twenty. The static model works — until the parts of commerce that change every minute meet it.

By Kenneth D'SilvaReading Time: 25 min readCategory: Architecture & Cloud

1. The Build That Took Four Hours and Twenty Minutes

A specialist lighting retailer moved from Magento 2 to a statically generated Next.js storefront in early 2024. The launch was genuinely good — first-visit LCP went from 3.9s to 1.2s, organic traffic climbed 18% over the following quarter, and the hosting bill dropped from about £1,400 a month to £310.

Then the catalogue grew. They added a commercial lighting range in September, went from 6,200 SKUs to 19,800, and the production build went from eleven minutes to four hours and twenty minutes.

The consequence was not that deployments were slow. The consequence was that the merchandising team stopped being able to work. A price correction on one product meant a full rebuild, so corrections were batched into a nightly deploy. A typo in a product description sat live for up to twenty-four hours. When a supplier discontinued a line mid-morning, the pages stayed up until the next night. Somebody in the office started calling it "the four o'clock", because if your change did not make the four o'clock cutoff it was tomorrow's problem.

They had built a static site for a business whose data changes continuously, and the build time was the bill arriving.

None of this means static generation is wrong for ecommerce. I still reach for it. But there is a specific set of things it does not do, and the failure mode is not that it breaks — it is that it works beautifully at 5,000 SKUs and becomes an operational tax at 20,000. This article is about where that line sits, how to move it, and which parts of a storefront should never have been static in the first place.

2. What JAMstack Means Now

The original definition — JavaScript, APIs, and Markup, pre-rendered and served from a CDN — has aged into something looser. Almost nobody ships a purely static commerce site any more, and the vendors who coined the term have quietly moved on to other language.

What survives, and what is actually worth adopting, is one architectural commitment: the HTML for a page is produced ahead of the request, not during it. Everything else — where the data comes from, whether there is a Node process, whether some routes render on demand — is negotiable.

That commitment buys you three things that are hard to get any other way. A CDN edge response has no origin round trip, so time to first byte is measured in tens of milliseconds regardless of where the customer is. A pre-rendered page cannot be slow because of a database query, because there is no query. And a static artefact cannot fall over under load, which means a mention on a popular account is a bandwidth event rather than an incident.

It also costs you something specific: the page reflects the state of the world at build time, not request time. Every problem in this article is a consequence of that one sentence.

The content layer question — Sanity, Contentful, Strapi, and how the CMS choice affects all of this — is a separate topic covered in the article on headless CMS SEO and performance. Here I am concerned with the commerce data: catalogue, stock, price, and cart.

3. Why Static Wins on a Catalogue, When It Wins

Worth stating the case properly before dismantling it.

A product page is, structurally, an excellent candidate for pre-rendering. The name, description, images, specifications, and category relationships change rarely — on the welding supplies retailer, the median product record had not been edited in fourteen months. The content is identical for every visitor. It is exactly the shape of thing static generation was designed for.

And the crawlability benefit is real and large. A pre-rendered page is complete HTML in the first response, which sidesteps the entire class of rendering problems described in the piece on PWA and SPA crawlability. On a catalogue of tens of thousands of low-authority pages, that difference determines how much of your site gets indexed at all.

The performance numbers on the lighting site, measured at the 75th percentile on mobile:

MetricMagento 2, cachedStatic, CDN
TTFB620ms48ms
LCP3.9s1.2s
INP310ms190ms
Origin cost per month£1,400£310
Peak sustained RPS before degradation~90Not reached in testing

The TTFB row is the one that matters and it is not close. Nothing you do to a PHP application gets you to 48ms in Sydney from an origin in London. That is a physics argument, and it is why I keep coming back to this architecture despite everything below.

4. The Build Time Curve

Build time on a static catalogue is roughly linear in page count, which sounds manageable and is the trap. Linear growth against a catalogue that doubles is a build that doubles, and there is no point on the curve where it gets better on its own.

The rough arithmetic, from builds I have measured on Next.js and Astro projects with a remote data source:

PagesNaive buildWith data batchingWith incremental cache
50050s35s30s
5,00011 min4 min2 min
20,0004h 20m18 min5 min
100,000Impractical1h 40m12 min

The jump from 5,000 to 20,000 in the naive column is worse than linear, and that is the detail worth understanding. It is not the rendering that degrades — rendering 20,000 React trees is a few minutes of CPU. It is the data fetching. A naive implementation makes one API call per page, sequentially or with modest concurrency, and at 20,000 pages you are making 20,000 requests to a commerce API that rate-limits you at 40 per second. That is eight minutes of pure waiting if everything is perfect, and considerably more when it is not, because a rate-limited request gets retried with backoff and the backoff compounds.

On the welding supplies retailer, profiling the four-hour build showed 92% of wall time in network waits against the Shopify Admin API, and about 6% in image processing. Actual page rendering was under two minutes.

Which tells you where to look first, and it is almost never where people look.

5. Making Builds Fast: The Levers, in Order

Fetch the catalogue once, not once per page. This is the single largest win available and it is usually a two-hour change. Pull the entire catalogue in a bulk operation at the start of the build, write it to a local file, and have every page read from that.

// scripts/fetch-catalogue.mjs — runs once, before the build.
// Bulk operations return a JSONL file instead of paginating a
// GraphQL query 20,000 times.
const mutation = `
  mutation {
    bulkOperationRunQuery(query: """
      { products { edges { node {
          id handle title descriptionHtml
          variants { edges { node { sku price barcode } } }
      } } } }
    """) { bulkOperation { id status } userErrors { message } }
  }`;

await gql(mutation);

// Poll until the operation completes, then download the JSONL.
let op;
do {
  await sleep(5000);
  op = (await gql('{ currentBulkOperation { status url objectCount } }'))
    .currentBulkOperation;
  console.log(op.status, op.objectCount);
} while (op.status === 'RUNNING');

const jsonl = await (await fetch(op.url)).text();
await writeFile('.cache/catalogue.jsonl', jsonl);

On the lighting site this took the data phase from 3h 58m to 90 seconds. One bulk operation instead of twenty thousand queries. The build went from 4h 20m to about 18 minutes without touching anything else.

Cache image processing across builds. The second biggest cost, and it is entirely wasted work — the same source image gets resized to the same six variants on every build. Key the cache on a hash of the source URL plus the transform parameters and persist it in CI.

# Persisting the image cache is usually worth more than any
# other CI optimisation on a catalogue build.
- uses: actions/cache@v4
  with:
    path: |
      .next/cache
      .cache/images
    key: build-cache-${{ github.sha }}
    restore-keys: build-cache-

Do not pre-render pages nobody visits. On the lighting catalogue, 38% of products had received zero organic sessions in twelve months. Pre-rendering the top 60% by traffic and rendering the rest on demand, cached at the edge after the first request, cut the build again with no user-visible difference. The first visitor to a long-tail product waits an extra 400ms; every visitor after that gets the cached copy.

// Next.js: pre-render the pages that matter, defer the rest.
export async function generateStaticParams() {
  const all = await getCatalogue();
  // Traffic data exported weekly from analytics into the build.
  const popular = await getTopSkus(0.6);
  return all
    .filter((p) => popular.has(p.sku))
    .map((p) => ({ handle: p.handle }));
}

// Anything not in the list above is generated on first request
// and then cached, rather than 404ing.
export const dynamicParams = true;

Parallelise across machines only after the above. Sharding a build across four workers is the lever people reach for first and it is the least effective, because it does nothing about rate limits — four workers hitting a 40-request-per-second API get 10 each. Fix the data access pattern and you probably will not need the shards.

6. Incremental Static Regeneration, Precisely

ISR is the mechanism that makes static viable for commerce, and it is widely misunderstood, so it is worth being exact about what happens.

A page is generated with a revalidation period — say 300 seconds. A request arrives. If the cached page is younger than 300 seconds, it is served from cache. If it is older, the cached page is still served, and a regeneration is triggered in the background. The next request after that regeneration completes gets the new version.

Three consequences fall out of that, and each one has caught somebody out.

At least one visitor always sees stale content. The request that triggers regeneration is served the old page. This is by design and it is correct — the alternative is making that visitor wait — but it means "revalidate every 60 seconds" does not mean "at most 60 seconds stale". It means at most 60 seconds plus however long it takes one more person to arrive.

On a long-tail page, staleness is unbounded. A product visited once a fortnight regenerates once a fortnight, no matter what revalidation period you set, because regeneration is request-driven. Your 60-second setting is meaningless for the 38% of the catalogue nobody visits. This surprises people badly, and it is the reason time-based revalidation alone is not a stock strategy.

Regeneration is per edge node in some setups. Depending on the platform, a page cached at the Frankfurt edge and the Sydney edge revalidates independently. Two customers in different regions can see different versions of the same page for a window, which makes bug reports interesting.

// Revalidation periods I actually use, and why.
export const revalidate = 3600;   // Product content: hourly is generous.
                                  // Descriptions and images change rarely.

// In the category route, shorter, because ordering and
// availability badges shift more often.
export const revalidate = 600;

// Never rely on this for price or stock. See below.

The right mental model: time-based revalidation is a safety net that stops pages drifting indefinitely. It is not a synchronisation mechanism. If you need a change to be live within a known time, you need the next section.

7. On-Demand Revalidation, Which Is the Actual Answer

Webhook-driven invalidation is what makes the merchandising team stop hating you. When a product changes in the backend, the backend tells the frontend, and that specific page regenerates within seconds.

// app/api/revalidate/route.js
import { revalidatePath, revalidateTag } from 'next/cache';
import crypto from 'node:crypto';

export async function POST(request) {
  const raw = await request.text();

  // Verify the webhook signature. An unauthenticated revalidation
  // endpoint is a free denial-of-service against your own build.
  const sig = request.headers.get('x-shopify-hmac-sha256');
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw, 'utf8')
    .digest('base64');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response('bad signature', { status: 401 });
  }

  const product = JSON.parse(raw);

  revalidatePath(`/products/${product.handle}`);
  // Tags let one event invalidate every page that used this data,
  // including category listings the product appears on.
  revalidateTag(`product:${product.id}`);
  for (const collection of product.collections ?? []) {
    revalidateTag(`collection:${collection.handle}`);
  }

  return Response.json({ revalidated: true });
}

Two details that matter more than the code.

The signature check is not optional. An open revalidation endpoint lets anyone force regeneration of arbitrary pages as fast as they can send requests, which on a platform that bills for compute is a way to spend your budget and, on a rate-limited commerce API, a way to get your own build blocked. I have seen this exploited once, not maliciously — a badly configured monitoring tool hitting a health check URL that had been wired to the revalidate handler.

The tag fan-out is where correctness lives. A price change on one product affects the product page, every category page it appears on, the search index, any "related products" module on other products, and the homepage if it is featured. Miss one and you have inconsistent prices across the site, which is the specific failure customers notice and complain about. Getting the dependency graph right is most of the real work in this architecture, and it is not something a tutorial will teach you because it depends entirely on your merchandising rules.

On the welding supplies retailer, wiring product, collection, and inventory webhooks to targeted revalidation reduced the median time from an edit to a live change from about fourteen hours to eleven seconds. That change, more than any performance number, is what made the architecture acceptable to the business.

8. Stock: The First Thing That Breaks

Here is where the static model starts genuinely failing rather than merely being inconvenient.

Stock changes on every order. On a busy storefront that is hundreds of changes an hour, arriving from web orders, marketplace channels, EPOS, and warehouse adjustments. There is no revalidation period short enough and no webhook volume sane enough to keep a pre-rendered availability badge accurate.

People try. The pattern I keep finding is a webhook on inventory updates triggering revalidation, which works fine on a small catalogue and falls over the moment you have volume — you end up regenerating the same page forty times an hour, each regeneration costing an API call and some compute, to keep a badge that says "In stock" saying "In stock".

The answer is to stop pre-rendering stock at all. Bake in what is stable, fetch what is not.

// The product page is static. The availability element is not.
// It renders a neutral placeholder in the static HTML and fills
// in on the client from a short-cached endpoint.
export function Availability({ sku, lastKnown }) {
  const [state, setState] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    fetch(`/api/stock/${sku}`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setState)
      .catch(() => {}); // Network failure: keep the build-time value.
    return () => controller.abort();
  }, [sku]);

  // lastKnown comes from the build and is what a crawler sees.
  // Keep it coarse: "available" or "discontinued", never a count.
  const shown = state ?? lastKnown;
  return <span data-live={Boolean(state)}>{label(shown)}</span>;
}

The build-time value in that component is deliberate and it needs care. Googlebot indexes what is in the HTML, so your availability in the Product schema comes from the build. If a product is out of stock for two days and your schema says InStock, you will get a Merchant Center mismatch and possibly a suspension. My rule: pre-render availability at a coarse grain that changes slowly — is this product still sold at all — and treat the precise, right-now number as strictly client-side and strictly not in the structured data.

The stock endpoint itself should be cached for 30-60 seconds at the edge. Not zero: a product page on a popular item can attract enough requests to make an uncached inventory lookup a real load on the commerce backend, and nobody needs second-by-second precision on a badge. Precision at the moment that matters — checkout — is a server-side validation, and it always was, on every architecture. A stock badge has always been a hint.

9. Pricing: The One That Ends Projects

Stock is a nuisance. Price is where I have seen static commerce projects abandoned.

The problem is not that prices change. Prices change slowly on most catalogues and webhook revalidation handles it. The problem is that on a serious storefront, price is not a property of a product. It is a function of the product, the customer, the quantity, the region, the currency, the active promotions, the tax rules, and the time of day.

A B2B distributor with customer-specific contract pricing has, in principle, one price per product per customer account. Twenty thousand products and eight hundred trade accounts is sixteen million prices. You are not pre-rendering that.

Even in consumer retail it gets ugly fast. VAT-inclusive display for UK visitors, VAT-exclusive for exports. Three currencies with rates that move. A promotion that applies to a collection between two timestamps. A bundle discount at quantity three. Each of those multiplies your cache key space, and cache key space is the actual constraint in this architecture.

What I do, in order of preference:

Pre-render the anonymous list price only. One price per product, the one an unauthenticated visitor in your primary market sees. That is what goes in the HTML, in the schema, and in the Merchant Center feed. It is consistent, it is crawlable, and it is honest, because it is genuinely the price for the majority of visitors.

Fetch anything customer-specific after load, and expect it to look bad. A trade customer sees the list price for 200ms and then it changes. This is a real experience problem and I have not found a fully satisfying answer. What helps: rendering the price element with a subtle loading treatment for authenticated sessions so the change reads as "loading your price" rather than "the price went up", and detecting the logged-in state from a cookie at the edge so you know before paint which treatment to use.

// middleware.js — the edge knows the session before the page renders.
export function middleware(request) {
  const isTrade = request.cookies.has('trade_session');
  const response = NextResponse.next();
  // The static page reads this header to decide whether to render
  // the list price as final or as a placeholder awaiting trade pricing.
  response.headers.set('x-price-mode', isTrade ? 'pending' : 'list');
  return response;
}

export const config = { matcher: ['/products/:path*'] };

For a small number of price variants, generate them all. Three currencies and two tax modes is six variants per page, which is a build you can afford. Route them by path — /uk/, /de/, /us/ — rather than by cookie, because path-based variants are separately cacheable, separately indexable, and can carry hreflang. Cookie-based price switching on a CDN-cached page is a category of bug I would rather not have again; the first time a Frankfurt edge node serves a euro price to a UK customer, you will understand why.

If pricing is genuinely per-customer, do not make the product page static. Render it on the server. Accept the 200ms TTFB. This is the honest answer for most B2B, and refusing to accept it is how the welding supplies retailer's competitor spent nine months building a caching layer that reimplemented, badly, a server they already had.

10. The Cart, Which Was Never the Problem

Cart and checkout come up constantly as objections to static commerce and they are the easy part.

A cart is client state plus a server API. It was never rendered into the page on any sensible architecture — even Magento serves the cart contents via a separate call so the page itself can be full-page-cached. Moving to a static frontend changes nothing about this. You hold a cart token, you call an API, you render the result on the client.

Checkout is similarly unaffected. Either you use the platform's hosted checkout, in which case it is somebody else's problem, or you build one, in which case it is a dynamic authenticated application that should never have been static and can happily be server-rendered alongside the static catalogue.

The one thing to watch is the cart count badge in the header, because the header is in every pre-rendered page. Render it empty in the static HTML and populate it on the client. If you render a count into the static page, every visitor sees whatever count was true at build time, which is a genuinely funny bug the first time and an embarrassing one when it reaches production.

// The header is baked into 20,000 pages. Anything personal in it
// must start empty and fill in, or you ship one customer's state
// to everybody.
export function CartBadge() {
  const [count, setCount] = useState(null);
  useEffect(() => {
    getCart().then((c) => setCount(c.itemCount));
  }, []);
  // null renders nothing at all — no zero, no skeleton, no shift.
  return count ? <span className="badge">{count}</span> : null;
}

The same principle applies to anything else personal in shared chrome: recently viewed items, a greeting with the customer's name, a loyalty points balance. Every one of those has to be a hole in the static HTML that gets filled client-side, and every one of those holes is a potential layout shift. Reserve the space.

11. Personalisation and the Cache Key Explosion

The deeper structural issue behind both pricing and personalisation is that a static page is a cache entry, and every dimension of variation multiplies the number of entries you need.

Country, currency, language, tax mode, customer tier, A/B test bucket, and a promotional segment gives you, for a modest configuration, something like 4 x 3 x 4 x 2 x 3 x 2 x 3 — over a thousand variants of every page. At 20,000 products that is twenty million artefacts and a build measured in weeks.

So the discipline is to keep the number of pre-rendered dimensions as small as you can defend, and to push everything else to the client or the edge. In practice I allow two: locale, which becomes a path prefix and is genuinely a different page with different content and its own hreflang, and nothing else. Currency follows from locale. Tax mode follows from locale. Customer tier is a client-side overlay. A/B tests run client-side or at the edge with a rewrite, never as separate builds.

Edge middleware is the pressure valve here and it is worth understanding its limits before relying on it. It runs before the cache, it can read cookies and geography, and it can rewrite to a different static variant. What it cannot do cheaply is generate content, and what it must not do is make the response uncacheable, which is what happens the first time somebody adds a Vary header on a cookie. The trade-offs of pushing logic to the edge are covered in more depth in the piece on edge computing for ecommerce.

12. Search and Faceted Navigation

Two things that look like catalogue pages and behave nothing like them.

Site search has an unbounded key space. You cannot pre-render results for arbitrary queries, and there is no reason to want to — search result pages should be noindexed anyway. Route search to a hosted service, render the results client-side, and stop thinking about it.

Faceted navigation is the interesting one, because some facet combinations are valuable landing pages and most are crawl waste. "Black pendant lights" is a page worth having and worth indexing. "Black pendant lights, 15-20cm, dimmable, in stock, sorted by price descending" is a URL that should exist for customers and be invisible to search engines.

The split I use: pre-render a curated list of facet combinations — typically the ones with search volume, which the SEO team can supply — as real static pages with proper titles, descriptions, and copy. Everything else is handled client-side against the search service and marked noindex. This gets you the landing pages without the index bloat, and it keeps the build finite.

// Curated facet landing pages, from a file the SEO team maintains.
// Anything not on this list resolves client-side and is noindexed.
import curated from '../data/facet-landing-pages.json';

export async function generateStaticParams() {
  return curated.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({ params }) {
  const page = curated.find((p) => p.slug === params.slug);
  if (!page) return { robots: { index: false, follow: true } };
  return {
    title: page.title,
    description: page.description,
    alternates: { canonical: `/c/${page.slug}` }
  };
}

Keeping that list in a JSON file the marketing team can edit through a pull request has worked better for me than any admin interface. It is version controlled, it is reviewable, and it makes the deliberate decision to create an indexable page visible to everyone.

13. Hydration, and How Much of It You Need

The other half of the phrase in this article's title. Having pre-rendered your HTML, how much JavaScript do you ship to make it interactive?

The default in a React framework is full hydration: the entire page tree is reconstructed in the browser and attached to the server-rendered DOM. On a product page where the only interactive elements are a quantity selector, an image gallery, and an add-to-cart button, this is enormously wasteful. You are shipping and executing the component code for the header, the footer, the description, the specification table, and the breadcrumbs, none of which will ever do anything.

The measurable cost is INP and the time to interactive. On the welding supplies retailer's original build, hydration was 340ms of main thread work on a mid-range Android, during which taps did nothing. Customers tapping "add to cart" in that window got no response and tapped again, which produced a small but real number of duplicate line items.

Islands architecture — Astro's model, and increasingly available elsewhere via server components — ships JavaScript only for the components that need it. Rebuilding the lighting product page as static HTML with three interactive islands took the hydration cost to 40ms and the JavaScript payload from 210KB to 34KB.

<!-- Astro: everything is static HTML unless you say otherwise. -->
<ProductGallery images={product.images} client:visible />
<!-- client:visible defers loading until it scrolls into view -->

<AddToCart sku={variant.sku} client:idle />
<!-- client:idle loads at browser idle; the button is a real
     form submit until then, so it works without JavaScript -->

<SpecificationTable specs={product.specs} />
<!-- No directive: pure HTML, zero JavaScript, forever -->

The detail I would underline is the progressive enhancement on the add-to-cart. Making it a real form post to a real endpoint that works before hydration means the most important interaction on the page is never dead. It costs a server route you would otherwise not need and it is the difference between a fast page and a page that only looks fast.

My honest position on islands: they are the right default for a catalogue, and they are harder to staff for. React developers are abundant and Astro developers are not, and a client who cannot hire for their stack has a problem no architecture diagram solves. On two projects I have used server components in Next.js instead, purely because the team already existed, and accepted a heavier result.

14. The Hybrid That Actually Works

Nothing above argues for a purely static site, and nothing argues against static. The useful outcome is a per-route decision.

RouteStrategyWhy
HomepageStatic, webhook revalidatedChanges on merchandising, not per request
Product, top 60% by trafficStatic, webhook revalidatedCrawl-critical, stable content
Product, long tailOn-demand, then cachedBuild cost not justified
Category, curated facetsStatic, hourly revalidateLanding pages with search value
Category, arbitrary facetsClient-side, noindexUnbounded key space
Search resultsClient-side, noindexUnbounded, no SEO value
Stock and live priceEdge API, 30-60s cacheChanges continuously
CartClient-side against APIPer-session state
Account, orders, wishlistServer-rendered, noindexAuthenticated, never cached
CheckoutServer-rendered or hostedMoney; correctness over speed
Blog and guidesFully staticThe easy case, and a real traffic source

Reading down that table, the static portion is the part of the site search engines care about and customers browse, and the dynamic portion is the part that involves money and identity. That division is not a coincidence — it is the same line every caching architecture has drawn since Varnish, and JAMstack is a restatement of it with better tooling.

15. The Lighting Retailer, Six Months On

What we changed, in order, and what each thing bought.

Bulk catalogue fetch. Two days of work. Build from 4h 20m to 18 minutes. The single highest-value change in the project by a wide margin.

Persistent image cache in CI. Half a day. 18 minutes to 11 minutes.

Webhook revalidation with tag fan-out. Two weeks, most of it spent working out the dependency graph between products, collections, and the homepage. Edit-to-live went from up to fourteen hours to about eleven seconds, and the four o'clock deadline stopped existing.

Stock and trade price moved to client fetch. One week. Eliminated a class of complaint about wrong availability, and stopped the inventory webhook from triggering thousands of unnecessary regenerations a day.

Long-tail products moved to on-demand. Three days. 11 minutes to about 4 minutes.

Final state: full build 4 minutes, targeted revalidation in seconds, hosting around £340 a month including the compute for on-demand rendering. Organic traffic up 31% against the pre-migration baseline at month nine, which I would attribute mostly to the speed and to having every product indexed rather than to anything clever.

What went wrong. The webhook fan-out had a gap for about six weeks. When a product's price changed we revalidated the product page and its collection pages, but not the "you may also like" modules on other products, which were built from a related-products list baked in at build time. So a product could show one price on its own page and a different, older price in the recommendation carousel on a sibling page. A customer noticed, screenshotted both, and posted it. It was not a legal problem because the checkout price was always correct, but it was an avoidable embarrassment and the fix — treating the recommendation module as a client-fetched component rather than baked data — was something we should have decided at design time.

The other thing I got wrong was optimism about the merchandising team's tolerance. In the first month, before webhook revalidation, I told them a fifteen-minute build was fine and they should batch their changes. It was not fine. A merchandiser making a typo correction expects to see it immediately, and any delay reads as the system being broken. I now treat edit-to-live latency as a headline requirement in these projects, gathered in the first week alongside the performance targets, rather than something to be optimised later.

16. Preview, and the Editor Experience

An underrated reason static commerce projects fail: content editors cannot see their work.

On a traditional CMS-backed site, an editor saves a draft and views it. On a statically generated site the page does not exist until it is built, so a naive implementation gives the editor nothing until deploy. Editors respond to this rationally by publishing to production to see what things look like, which is exactly the behaviour you did not want.

Draft mode solves it: a signed cookie that switches specific routes from cached static output to live server rendering against draft data.

// app/api/preview/route.js
import { draftMode } from 'next/headers';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  // The token is issued by the CMS and scoped to one document,
  // so a leaked preview link does not expose the whole draft site.
  const valid = await verifyPreviewToken(searchParams.get('token'));
  if (!valid) return new Response('invalid token', { status: 401 });

  draftMode().enable();
  return Response.redirect(new URL(searchParams.get('path'), request.url));
}

Two things worth doing alongside it. Put a persistent, obvious banner on the page when draft mode is active, because an editor who forgets they are in preview will report bugs about content that is not live. And make sure preview routes send noindex and are excluded from the CDN cache, because a preview URL that gets shared and indexed is a draft in the search results.

17. When I Would Not Use This Architecture

Per-customer pricing across the whole catalogue. If every visitor sees a different price on every product, pre-rendering the price is impossible and pre-rendering everything except the price gets you a page that flashes and a Merchant Center feed you cannot trust. Server-render it and use a conventional cache with the session in the key. This is most serious B2B.

A catalogue that changes structurally every day. Flash sale sites, auction formats, anything where the set of available products is different by the hour. The revalidation volume approaches the request volume and you have built a slow, complicated dynamic site.

A team without a deployment culture. This architecture assumes CI, environment management, and people comfortable with a build failing. A retailer whose technical capability is one part-time developer editing templates through an admin panel will find it hostile, and the fact that it is architecturally superior will not help them.

Under about a thousand SKUs, on a platform that already works. The absolute gains are real but the migration cost is not proportionally smaller. A well-configured Shopify or Magento site with a decent theme, a CDN, and someone who has done the performance basics will get you a long way for a fraction of the effort. I have talked two clients out of this migration on exactly those grounds and do not regret either.

When the actual problem is something else. The most common one: a site is slow because of 900KB of third-party tags, and somebody has proposed a rearchitecture. Static generation will not remove the tags. It will give you a 40ms TTFB followed by the same 4 seconds of tag manager. Measure where the time goes before committing to a rebuild.

18. Questions That Come Up

"Is JAMstack dead? The vendors stopped using the word." The word is out of fashion and the architecture is now mainstream enough not to need a name. Pre-rendering, CDN delivery, and API-driven data are in every major framework's defaults. Ignore the branding argument.

"How many products before builds become a problem?" With naive per-page fetching, around 3,000. With bulk data fetching and a persistent cache, I have run 60,000-page builds in under fifteen minutes. The catalogue size is rarely the real constraint; the data access pattern is.

"Can I do this on Shopify?" Yes, and it is the most common version of this project. The Storefront API and bulk operations are well suited to it, checkout stays hosted so the hardest problem is not yours, and Hydrogen exists if you want an opinionated starting point. The trade-offs of that specific route are covered in headless commerce SEO and performance.

"What happens when the commerce API goes down mid-build?" The build fails, and that is the correct behaviour — you do not want a deploy that silently drops half the catalogue. What you must have is a deployment model where a failed build leaves the previous version serving, which every serious platform does by default and which you should verify rather than assume. Test it deliberately once.

"Do we still need a service worker?" It is orthogonal. A statically generated site is already fast on first visit, so the marginal benefit of caching is smaller than on a slow origin — but the offline and repeat-visit behaviour is unchanged, and the same cautions apply. The caching strategies and the kill switch matter just as much here.

"How do we handle a product being deleted?" Deliberately, because static artefacts persist. A deleted product needs its page removed from the deploy and a 410 or a 301 configured, and if your build simply stops emitting the page you may be left with a stale artefact on the CDN or a 404 with no redirect. Make deletion an explicit webhook path, not something that falls out of the next build.

"Is the cost saving real?" On origin hosting, yes and substantially. Be careful about the rest: build minutes, image transformations, edge function invocations, and on-demand rendering are all metered on most platforms, and a badly tuned site can spend more on regeneration than it saved on servers. Model it at your actual traffic before quoting anyone a number.

19. What I Would Do First

One. Profile your existing build if you have one, and find out what fraction of wall time is data fetching. On every project I have looked at it has been the majority, and fixing it is days rather than months.

Two. Write down, before any code, the edit-to-live latency the business needs. If merchandisers need a price change visible in under a minute, you need webhook revalidation from day one and time-based revalidation will not save you.

Three. Draw the dependency graph. Which pages change when a product changes, when a collection changes, when a price changes. This is the artefact that determines whether your revalidation is correct, and it is worth an hour on a whiteboard with a merchandiser in the room.

Four. Decide what is never pre-rendered: stock, customer-specific price, cart, anything authenticated. Get that list agreed before anyone writes a component, because retrofitting a value out of the static HTML is much harder than leaving it out.

Five. Pre-render the traffic-carrying portion of the catalogue and defer the tail. You will not be able to tell the difference and your build will be a quarter of the size.

Six. Build the preview flow before you hand the CMS to anyone. An editor who cannot see their draft will publish to production to look at it, and you will spend the next year explaining why that is a problem.

The welding supplies retailer still runs this architecture and would not go back. But the thing that made it work was not the static generation — it was accepting, after four painful months, that a storefront has a stable half and a live half, and that trying to pre-render the live half is a fight with the shape of the business rather than with the tooling.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: