David Lewin David Lewin
E-Commerce, Research & Ads

Next.js and Shopify headless: skip the full rebuild with Storyblok webhooks and on-demand ISR

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

Don't rebuild a headless Shopify storefront when a price or a paragraph changes. Tag each cached fetch (product:handle for Shopify, story:slug for Storyblok), point Shopify's products/update webhook and Storyblok's publish webhook at two small Next.js route handlers, verify their signatures, and call revalidateTag. Only the pages using that tag re-render, on their next visit. In our test with Next.js 16.4, a full build of 5,000 product pages took 62.6 seconds against a zero-latency mock API, while a webhook plus the next page view showed the new price in about 0.1 seconds. Use { expire: 0 } for prices so nobody sees the old one, and 'max' for editorial content.

Headless Shopify starts fast: a Next.js storefront, products from the Storefront API, landing pages and product copy from a CMS, everything prerendered. Then the catalog grows, marketing edits copy ten times a day, and every change triggers a full build and deploy. Prices lag by minutes, builds queue behind each other, and somebody suggests making every page dynamic, which throws away the speed that justified going headless. This guide shows the way out that Next.js was built for: cache each piece of data under a tag, and let Shopify and Storyblok tell your app exactly which tags changed.

How this guide was made

API behavior comes from the Next.js 16.4 documentation, Shopify's webhook docs, Storyblok's webhook, caching and pricing pages, and Vercel's ISR pricing page, checked in October 2026. The code was built and run with Next.js 16.4.2 on a Windows laptop with 8 threads, against a local mock of the Shopify Storefront API and the Storyblok Content Delivery API. Build times are from that machine with zero API latency: real APIs add network time and rate limits. It was not run against a live Shopify store or Storyblok space, or deployed to Vercel or a VPS.

1. The short answer

Full rebuild on every changeTime-based ISR (revalidate = 60)On-demand ISR with webhooks
Price visible after a changeAfter build and deployUp to the interval, plus one visitNext visit
Work per changeEvery page, every API callEvery visited page, every interval, changed or notOnly pages that use the changed tag
Grows with catalog sizeYes: build time and API callsYes: background refreshesNo
Moving partsCI pipelineNoneTwo route handlers and two webhook secrets
FitsSmall, rarely changing sitesData without change eventsShopify and Storyblok, which both send webhooks

2. The build trap, measured

A full build renders every prerendered page and calls the APIs for each one. We built the same app with 100, 1,000 and 5,000 prerendered product pages against a mock API that answers instantly, then measured how long a price change took to appear through a webhook:

Time until a changed price is live
Time until a changed price is liveFull build, 100 product pages: 7 seconds; Full build, 1,000 product pages: 15.2 seconds; Full build, 5,000 product pages: 62.6 seconds; Webhook plus the next page view: 0.1 secondsFull build, 100 product pages: 7 secondsFull build, 100 product pages7 secondsFull build, 1,000 product pages: 15.2 secondsFull build, 1,000 product pages15.2 secondsFull build, 5,000 product pages: 62.6 secondsFull build, 5,000 product pages62.6 secondsWebhook plus the next page view: 0.1 secondsWebhook plus the next page view0.1 seconds
Measured with Next.js 16.4.2 on an 8-thread laptop, mock APIs with no network latency. Build figures exclude deployment, CDN propagation and real API latency, which all add to them. The webhook figure is the route handler (about 5 to 20 ms) plus the first page view after it, which re-rendered the page (about 85 ms).

Two things don't show in the chart. First, build time is the best case: at 5,000 pages with two API calls each, real network latency stretches it further, and so do rate limits: Storyblok, for example, allows 50 uncached single-story requests a second. Second, builds queue. Ten edits an hour means ten builds, each re-rendering thousands of pages that didn't change.

3. Full rebuild vs on-demand ISR

Full rebuild: one price change, every page
Full rebuild: one price change, every pagePrice changed in Shopify → CI build hook → DeployPrice changed in Shopify: products/update webhookPrice changed in Shopifyproducts/update webhookCI build hook: next build: every prerendered page, two API calls eachCI build hooknext build: every prerendered page, two API calls eachDeploy: upload output, switch traffic, CDN cache replacedDeployupload output, switch traffic, CDN cache replaced
Seconds to minutes per change once deployment is included, growing with catalog size, and changes made during a build wait for the next one.
On-demand ISR: one price change, one tag
On-demand ISR: one price change, one tagPrice changed in Shopify / Story published in Storyblok → Route handler → Next visit to an affected pagePrice changed in Shopify: products/update with the product handlePrice changed in Shopifyproducts/update with theproduct handleStory published in Storyblok: published with the full slugStory published inStoryblokpublished with the full slugRoute handler: verify the signature, call revalidateTag for that tag, answer in millisecondsRoute handlerverify the signature, call revalidateTag for that tag, answerin millisecondsNext visit to an affected page: re-renders that page only; every other page stays cachedNext visit to an affected pagere-renders that page only; every other page stays cached
No build, no deploy. Work is proportional to what changed and to which pages people actually visit.

4. Tag the data, not the pages

In Next.js 16, fetch isn't cached unless you ask, and a POST, which is what every GraphQL call is, needs cache: 'force-cache' explicitly. Each cached fetch can carry tags. Product data gets product:<handle> plus a broad products tag; the best-seller list gets product-list. There's deliberately no revalidate interval: webhooks decide when data is stale.

// lib/shopify.ts
// Storefront API: public, read-only, cached by Next.js under one tag per product
const ENDPOINT = process.env.SHOPIFY_STOREFRONT_URL!  // https://STORE.myshopify.com/api/2025-10/graphql.json

export type Product = {title: string; handle: string; price: {amount: string; currencyCode: string}}

async function storefront<T>(query: string, variables: object, tags: string[]): Promise<T> {
  const res = await fetch(ENDPOINT, {
    method: 'POST',
    headers: {'Content-Type': 'application/json',
              'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_TOKEN!},
    body: JSON.stringify({query, variables}),
    cache: 'force-cache',  // POST is only cached when you opt in
    next: {tags},          // no time-based revalidate: webhooks decide when data is stale
  })
  if (!res.ok) throw new Error(`Storefront API ${res.status}`)  // throwing keeps the last good page
  const json = await res.json()
  if (json.errors) throw new Error(JSON.stringify(json.errors))
  return json.data
}

export async function getProduct(handle: string): Promise<Product | null> {
  const d = await storefront<{product: any}>(
    `query($h: String!) { product(handle: $h) { title handle
       priceRange { minVariantPrice { amount currencyCode } } } }`,
    {h: handle}, [`product:${handle}`, 'products'])
  return d.product && {...d.product, price: d.product.priceRange.minVariantPrice}
}

export async function getTopHandles(n: number): Promise<string[]> {
  const d = await storefront<{products: {nodes: {handle: string}[]}}>(
    `query($n: Int!) { products(first: $n, sortKey: BEST_SELLING) { nodes { handle } } }`,
    {n}, ['product-list'])
  return d.products.nodes.map((p) => p.handle)
}
// lib/storyblok.ts
const CDN = process.env.STORYBLOK_CDN ?? 'https://api.storyblok.com/v2/cdn'

export type Story = {name: string; full_slug: string; content: {headline?: string; body?: string}}

// No cv parameter on purpose: Storyblok redirects to the latest cache version, so a revalidation
// triggered by a publish webhook gets the new content instead of a CDN copy of the old one.
export async function getStory(slug: string): Promise<Story | null> {
  const url = `${CDN}/stories/${slug}?version=published&token=${process.env.STORYBLOK_TOKEN}`
  const res = await fetch(url, {cache: 'force-cache', next: {tags: [`story:${slug}`]}})
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Storyblok ${res.status}`)
  return (await res.json()).story
}

The product page prerenders the best sellers and renders everything else on its first visit. That keeps build time flat however large the catalog gets: in the test, a product outside the prerendered set rendered once on its first visit (one API call) and was served from cache on the second.

// app/products/[handle]/page.tsx
import {notFound} from 'next/navigation'
import {getProduct, getTopHandles} from '@/lib/shopify'
import {getStory} from '@/lib/storyblok'

// Prerender the best sellers at build time; every other product renders on its first visit and is
// cached from then on. Build time stays flat as the catalog grows.
export async function generateStaticParams() {
  return (await getTopHandles(Number(process.env.PRERENDER_COUNT ?? 100))).map((handle) => ({handle}))
}
export const dynamicParams = true

export default async function ProductPage({params}: {params: Promise<{handle: string}>}) {
  const {handle} = await params
  const [product, story] = await Promise.all([getProduct(handle), getStory(`products/${handle}`)])
  if (!product) notFound()
  return (
    <main>
      <h1>{story?.content.headline ?? product.title}</h1>
      <p className="price">{product.price.amount} {product.price.currencyCode}</p>
      {story?.content.body && <p className="story">{story.content.body}</p>}
    </main>
  )
}

5. The Shopify webhook route

Subscribe products/update and products/delete to /api/revalidate/shopify. Shopify signs the raw body with HMAC-SHA256, base64-encoded, in X-Shopify-Hmac-Sha256, and expects an answer within 5 seconds. Revalidating is just marking tags, so this handler answers in milliseconds.

// app/api/revalidate/shopify/route.ts
import {createHmac, timingSafeEqual} from 'node:crypto'
import {revalidateTag} from 'next/cache'

// Shopify webhooks: products/update and products/delete, Admin API JSON payload
export async function POST(req: Request) {
  const raw = await req.text()  // the HMAC covers the raw body: verify before parsing
  const expected = createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!).update(raw).digest()
  const given = Buffer.from(req.headers.get('x-shopify-hmac-sha256') ?? '', 'base64')
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return new Response('bad signature', {status: 401})
  }
  const topic = req.headers.get('x-shopify-topic')
  const {handle} = JSON.parse(raw)
  if (topic === 'products/update' && typeof handle === 'string') {
    // Prices must not be served stale: expire now, so the next visitor waits for a fresh render
    revalidateTag(`product:${handle}`, {expire: 0})
    revalidateTag('product-list', 'max')  // best-seller order can lag: serve stale, refresh in background
  } else if (topic === 'products/delete') {
    // The delete payload carries only the ID, not the handle: expire every product. Deletes are rare,
    // and each page refetches only when someone visits it.
    revalidateTag('products', {expire: 0})
  }
  return Response.json({ok: true})  // well inside Shopify's 5-second limit: nothing is rendered here
}
  • Prices expire, they don't go stale. revalidateTag(tag, {expire: 0}) means the next visitor waits for a fresh render (about 85 ms in the test) instead of seeing the old price. Nobody should add an item to the cart at a price that no longer exists.
  • Deletes carry only an ID. The products/delete payload has no handle, so the handler expires the broad products tag. Every product page refetches on its next visit, once. Deletes are rare enough that this beats keeping an ID-to-handle map.
  • Verify before parsing. The signature covers the raw bytes; req.text() first, JSON.parse after.

6. The Storyblok webhook route

In Storyblok, add a webhook for story published, unpublished, deleted and moved, with a secret. The payload includes action and full_slug, so the tag is known without another API call. Storyblok sends the signature in the webhook-signature header; its own examples compute it as HMAC-SHA1 of the raw body, hex-encoded.

// app/api/revalidate/storyblok/route.ts
import {createHmac, timingSafeEqual} from 'node:crypto'
import {revalidateTag} from 'next/cache'

// Storyblok webhooks: story published, unpublished, deleted and moved. Signature is HMAC-SHA1, hex.
export async function POST(req: Request) {
  const raw = await req.text()
  const expected = createHmac('sha1', process.env.STORYBLOK_WEBHOOK_SECRET!).update(raw).digest('hex')
  const given = req.headers.get('webhook-signature') ?? ''
  if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return Response.json({error: 'bad signature'}, {status: 401})
  }
  const {action, full_slug} = JSON.parse(raw)
  if (typeof full_slug === 'string' && ['published', 'unpublished', 'deleted', 'moved'].includes(action)) {
    // Editorial copy can be a few seconds stale: the next visitor gets the old page, the one after the new
    revalidateTag(`story:${full_slug}`, 'max')
  }
  return Response.json({ok: true})  // Storyblok expects 2xx with a JSON content type, and doesn't retry
}
  • Copy can be stale for one visit. 'max' serves the cached page to the next visitor and regenerates in the background, so nobody waits for a render because an editor fixed a comma. The test shows it: first visit after the webhook returned the old copy, the next one returned the new.
  • No retries. Storyblok doesn't retry failed webhooks, and expects a 2XX with a JSON content type. If your app was down during a publish, that page stays stale until the next publish. Check the webhook logs in Storyblok after an outage, or add a long fallback revalidate as a safety net.
  • The CDN cache version. Storyblok's Content Delivery API caches responses by a cv parameter. Leaving it out makes the API redirect to the latest version, so a refetch after a publish gets the new content instead of a CDN copy of the old one.
  • Previews bypass all of this. The Storyblok visual editor needs draft content; use Next.js Draft Mode for preview requests, which skips the cache entirely.

7. Test results

The test builds the app, starts next start, and changes data in the mock APIs between requests:

StepExpectedResult
Prerendered product pageServed from cache, no API callPass: 49.00, 4 to 10 ms
Price changed to 39.00 in Shopify, no webhookStill cachedPass: 49.00, zero API calls
Webhook with a forged signature401, cache untouchedPass
products/update webhook, then one visitNew price, one API callPass: 39.00 on the first visit
A different product pageNot re-renderedPass: zero API calls
Storyblok publish webhook, two visitsOld copy, then new copyPass: v1, then v2
Storyblok webhook with a wrong secret401Pass
Product outside the prerendered set, two visitsRendered once, then cachedPass: one API call
products/delete webhook (ID only)404 on the next visitPass

8. Hosting: Vercel or a VPS

Vercel runs all of this with no extra setup: revalidateTag also clears its CDN. The cost model is worth knowing before you choose time-based revalidation. Vercel serves ISR pages from a free regional CDN cache backed by a durable ISR cache, and bills reads and writes to the durable cache in 8 KB units, priced by region. A revalidation that produces identical output costs no write, but anything non-deterministic in the page, like a timestamp or random ordering, turns every revalidation into a billed write. On-demand revalidation keeps writes to real changes.

Vultr or DigitalOcean, running next start on a VPS, works for this pattern with one instance and a persistent disk: the cache lives on the server's filesystem. Three things change compared with Vercel:

  • More than one instance needs a shared cache. Each instance has its own cache on disk, and a webhook reaches only one of them. Next.js documents a custom cacheHandler (with in-memory caching off) backed by Redis or similar, which must also index entries by tag so one revalidateTag call reaches every instance.
  • A CDN in front won't be purged for you. Our pages were served with Cache-Control: s-maxage=31536000, one year, because no time-based interval was set. A CDN that honors s-maxage would keep showing the old price no matter what Next.js revalidates. Either don't cache HTML at the CDN or purge the page's URL from the same webhook.
  • The data cache survives builds. In the test, rebuilding without deleting .next/cache reused cached fetch results, and the new build served the old price. On a VPS that keeps the directory between deploys, that's useful, since unchanged data isn't refetched, but if you rebuild to fix stale data, clear the cache too.

Build in CI and ship the output, so the VPS only runs next start: a 5,000-page build is a burst of CPU and API calls you don't want on the server that answers shoppers.

9. Our opinion: trade-offs

  • On-demand ISR is the default for headless Shopify. Both Shopify and Storyblok tell you what changed. Using that costs two route handlers; ignoring it costs a build per change.
  • Different freshness for different data. Prices and availability expire immediately; copy and images can be one visit stale. Choosing the profile per tag is the main design decision here.
  • Keep a slow fallback. Webhooks get lost: Storyblok doesn't retry, and Shopify gives up after 8 attempts over 4 hours. A daily rebuild or a long revalidate interval as a backstop bounds how stale anything can get.
  • Why Storyblok for this. Publish webhooks with the slug in the payload, a documented signature, and a CDN cache version that a refetch can bypass are what make tag-level revalidation simple. The free Starter plan includes 3 webhooks, enough for this setup; Growth is $99 a month ($90.75 billed yearly) with 5 webhooks and 1 million API requests.
  • Vercel vs VPS. Vercel if you want revalidation and CDN purging handled for you and accept usage-based ISR billing. A VPS if you run one instance or are ready to add a shared cache handler and handle CDN purging yourself.

Related: API rate limits when syncing 50k products, caching cart and checkout safely and choosing a VPS.

10. Tools

Top picks

ToolRoleWhy it fits
StoryblokHeadless CMS for landing pages and product copyPublish webhooks with the slug and a signature, a visual editor for marketers, and a free Starter plan with 3 webhooks and 100,000 API requests a month. New spaces get a 45-day Growth Plus trial.
VultrVPS for next startHourly-billed cloud compute in many regions; a good fit for a single Next.js instance with a persistent disk, with CI doing the builds.
DigitalOceanVPS or managed Redis for a shared cacheDroplets for the app and a managed Redis-compatible database if you grow to several instances and need a shared cacheHandler.

Alternatives: free and official tools

We don't earn anything from these links.

11. Pitfalls

  • Forgetting cache: 'force-cache' on GraphQL calls. Without it, POST requests aren't cached and every visit hits the Storefront API.
  • The one-argument revalidateTag(tag). Deprecated in Next.js 16. Pass 'max' or {expire: 0} on purpose.
  • Revalidating in the wrong place. revalidateTag works in Route Handlers and Server Functions; updateTag is for Server Actions only.
  • Non-deterministic output. A "generated at" timestamp makes every revalidation a changed page, and on Vercel a billed write.
  • A CDN honoring the one-year s-maxage. Revalidation never reaches visitors unless the CDN is purged too.
  • Several VPS instances with the default cache. The webhook revalidates one instance; the others keep serving the old price.
  • Assuming webhooks always arrive. Keep a slow fallback revalidation or a scheduled rebuild.
Bottom line

Tag every cached fetch by product handle and story slug, let Shopify's products/update and Storyblok's publish webhooks call revalidateTag through two signed route handlers, expire prices immediately and let copy go stale for one visit. In our test that turned a 62-second, 5,000-page rebuild into about a tenth of a second per change. On a VPS, add a shared cache handler before a second instance and keep your CDN from holding HTML for a year.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.