Next.js + Shopify Storefront API Caching Architecture for Flash-Sale Stability
Implement a resilient Next.js and Shopify Storefront API cache model with tagged revalidation, webhook invalidation, and cart-safe no-store flows.
If catalog, inventory, and cart traffic share the same cache policy, your store will either serve stale commerce data or overload origin APIs during peaks.
Where this breaks on scaling stores
- Teams put
cache: 'no-store'everywhere and destroy throughput. - Teams cache cart responses and return stale quantities or stale pricing.
- Webhooks are not connected to cache invalidation tags.
- Buyer identity/IP headers are inconsistent, causing avoidable API issues.
Data volatility model
Split storefront traffic into three cache classes:
catalogfor product/collection content that can tolerate minute-level staleness.inventoryfor low-latency stock updates.cartfor strictly uncached operations.
Build a central fetch contract
type CacheClass = "catalog" | "inventory" | "cart";
type ShopifyFetchInput = {
query: string;
variables?: Record<string, unknown>;
cacheClass: CacheClass;
tags?: string[];
buyerIp?: string;
};
const CACHE_POLICY: Record<CacheClass, {
cache: RequestCache;
revalidate?: number;
tags?: string[];
}> = {
catalog: { cache: "force-cache", revalidate: 900, tags: ["catalog"] },
inventory: { cache: "force-cache", revalidate: 20, tags: ["inventory"] },
cart: { cache: "no-store" }
};
Enforce no mixed caching directives
export async function shopifyFetch<T>(input: ShopifyFetchInput): Promise<T> {
const policy = CACHE_POLICY[input.cacheClass];
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": process.env.SHOPIFY_STOREFRONT_TOKEN || ""
};
if (input.buyerIp) {
headers["Shopify-Storefront-Buyer-IP"] = input.buyerIp;
}
const requestInit: RequestInit & {
next?: { revalidate?: number; tags?: string[] };
} = {
method: "POST",
headers,
cache: policy.cache,
body: JSON.stringify({ query: input.query, variables: input.variables || {} })
};
if (policy.cache !== "no-store") {
requestInit.next = {
revalidate: policy.revalidate,
tags: [...(policy.tags || []), ...(input.tags || [])]
};
}
const res = await fetch(
`${process.env.SHOPIFY_STORE_DOMAIN}/api/2026-01/graphql.json`,
requestInit
);
if (!res.ok) throw new Error(`Storefront API failed: ${res.status}`);
const json = await res.json();
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
return json.data as T;
}
Catalog and cart query segregation
Cached catalog query
const COLLECTION_QUERY = `
query CollectionByHandle($handle: String!) {
collection(handle: $handle) {
id
title
products(first: 24) {
nodes {
id
handle
title
featuredImage { url altText width height }
}
}
}
}
`;
export function getCollection(handle: string) {
return shopifyFetch({
query: COLLECTION_QUERY,
variables: { handle },
cacheClass: "catalog",
tags: [`collection:${handle}`]
});
}
Uncached cart mutation
const CART_LINES_ADD = `
mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart { id totalQuantity }
userErrors { field message }
}
}
`;
export function addCartLines(cartId: string, lines: Array<{ merchandiseId: string; quantity: number }>) {
return shopifyFetch({
query: CART_LINES_ADD,
variables: { cartId, lines },
cacheClass: "cart"
});
}
Webhook-driven invalidation
Revalidate tags from Shopify webhook topics
// app/api/revalidate/shopify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
function validHmac(payload: string, received: string | null): boolean {
if (!received) return false;
const digest = createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET || "")
.update(payload, "utf8")
.digest("base64");
return timingSafeEqual(Buffer.from(digest), Buffer.from(received));
}
export async function POST(req: NextRequest) {
const raw = await req.text();
if (!validHmac(raw, req.headers.get("x-shopify-hmac-sha256"))) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const topic = req.headers.get("x-shopify-topic");
if (topic === "products/update" || topic === "products/delete") {
revalidateTag("catalog", "max");
}
if (topic === "inventory_levels/update") {
revalidateTag("inventory", "max");
}
return NextResponse.json({ ok: true });
}
UI path: Settings > Notifications > Webhooks.
Technical Checklist
- One central Storefront fetch wrapper enforces cache policy by data class.
-
cartoperations always usecache: no-store. - Catalog and inventory use separate tags and TTLs.
- Shopify webhooks trigger
revalidateTagwith HMAC verification. - Buyer IP forwarding is consistent for all server-side Storefront requests.
- Load testing covers flash-sale concurrency and cache-hit behavior.
SEO Checklist
- Collection/product content is cacheable to improve TTFB and crawl efficiency.
- Critical SEO pages do not depend on cart-state rendering.
- Canonical and metadata generation do not rely on uncached downstream calls.
- Fast cache-hit responses are preserved for bots and first-time mobile users.
- Inventory invalidation does not introduce indexable soft-404 behavior.