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.
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.
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 change | Time-based ISR (revalidate = 60) | On-demand ISR with webhooks | |
|---|---|---|---|
| Price visible after a change | After build and deploy | Up to the interval, plus one visit | Next visit |
| Work per change | Every page, every API call | Every visited page, every interval, changed or not | Only pages that use the changed tag |
| Grows with catalog size | Yes: build time and API calls | Yes: background refreshes | No |
| Moving parts | CI pipeline | None | Two route handlers and two webhook secrets |
| Fits | Small, rarely changing sites | Data without change events | Shopify 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:
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
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/deletepayload has no handle, so the handler expires the broadproductstag. 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.parseafter.
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
revalidateas a safety net. - The CDN cache version. Storyblok's Content Delivery API caches responses by a
cvparameter. 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:
| Step | Expected | Result |
|---|---|---|
| Prerendered product page | Served from cache, no API call | Pass: 49.00, 4 to 10 ms |
| Price changed to 39.00 in Shopify, no webhook | Still cached | Pass: 49.00, zero API calls |
| Webhook with a forged signature | 401, cache untouched | Pass |
products/update webhook, then one visit | New price, one API call | Pass: 39.00 on the first visit |
| A different product page | Not re-rendered | Pass: zero API calls |
| Storyblok publish webhook, two visits | Old copy, then new copy | Pass: v1, then v2 |
| Storyblok webhook with a wrong secret | 401 | Pass |
| Product outside the prerendered set, two visits | Rendered once, then cached | Pass: one API call |
products/delete webhook (ID only) | 404 on the next visit | Pass |
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 onerevalidateTagcall 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 honorss-maxagewould 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/cachereused 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
revalidateinterval 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
| Tool | Role | Why it fits |
|---|---|---|
| Headless CMS for landing pages and product copy | Publish 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. | |
VPS for next start | Hourly-billed cloud compute in many regions; a good fit for a single Next.js instance with a persistent disk, with CI doing the builds. | |
| VPS or managed Redis for a shared cache | Droplets 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.
- Vercel ISR pricing: how ISR reads and writes are measured and billed.
- Next.js
revalidateTag: profiles,{expire: 0}and the deprecated one-argument form. - Next.js self-hosting guide: cache handlers, multiple instances and CDNs.
- Storyblok webhooks and cache invalidation: events, payload and the
cvparameter. - Shopify HTTPS webhooks: signatures, timeouts and retries.
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.
revalidateTagworks in Route Handlers and Server Functions;updateTagis 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.
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.