Caching Strategy
Reference: Next.js Cache Components — the official documentation for use cache , cacheLife , cacheTag , and Partial Prerendering.
When to Use
Use this skill when:
-
Understanding which data is cached vs. real-time
-
Debugging why content isn't updating after Dashboard changes
-
Configuring Saleor webhooks for cache invalidation
-
Modifying the /api/revalidate endpoint
-
Working with "use cache" and Suspense patterns
Data Freshness Model
The Key Principle
Display pages are cached for performance. Transactional flows are always real-time.
Page/Component Data Source Freshness Why
PDP (Product Detail) getProductData()
⚠️ Cached (5 min TTL) Performance - instant loads
Category/Collection pages getCategoryData() / getCollectionData()
⚠️ Cached (5 min TTL) Performance
Homepage getFeaturedProducts()
⚠️ Cached (5 min TTL) Performance
Navigation NavLinks
⚠️ Cached (1 hour TTL) Rarely changes
Cart Drawer Checkout.find()
✅ Always fresh Uses cache: "no-cache"
Checkout Page useCheckoutQuery()
✅ Always fresh Direct API call via urql
Add to Cart action Saleor mutation ✅ Always fresh Saleor calculates price
Price Flow Diagram
┌─────────────────────────────────────────────────────────────────────┐ │ PRICE FLOW │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ PDP Display Cart/Checkout Payment │ │ ──────────── ───────────── ─────── │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ Cached │───────▶│ FRESH │────────▶│ FRESH │ │ │ │ $29.99 │ Add │ $35.99 │ Pay │ $35.99 │ │ │ └───────────┘ to └───────────┘ └───────────┘ │ │ Cart │ │ "use cache" cache:"no-cache" Saleor validates │ │ 5 min TTL Always from API at checkout │ │ │ └─────────────────────────────────────────────────────────────────────┘
⚠️ User may see different price in cart than on PDP if price changed. ✅ User CANNOT checkout at stale price - Saleor always uses current price.
Why This Is Safe
-
Saleor is the source of truth: When you call checkoutLinesAdd , Saleor calculates the price server-side using current data
-
Cart always fetches fresh: Checkout.find() uses cache: "no-cache"
-
Checkout validates: checkoutComplete will fail if something is wrong
-
Webhooks enable instant updates: When configured, price changes trigger immediate cache invalidation
Cache Components Architecture
What It Is
Cache Components enable Partial Prerendering (PPR) - mixing static, cached, and dynamic content in a single route. The static shell is served instantly from CDN, while dynamic parts stream in via Suspense.
Current Status: ✅ ENABLED (Experimental)
⚠️ Note: Cache Components are still marked experimental in Next.js. The patterns are functional but evolving. See Disabling Cache Components if you need to rollback.
Cache Components are enabled in next.config.js :
const config = { cacheComponents: true, };
How It Works
┌─────────────────────────────────────────────────────────────────┐ │ STATIC SHELL (Instant from CDN) │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Header skeleton, layout, cached product data │ │ │ │ Source: "use cache" functions (getProductData, etc.) │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ <Suspense fallback={<Skeleton />}> │ │ │ │ Dynamic content (streams in after initial render) │ │ │ │ - Variant selection (reads searchParams) │ │ │ │ - Logo, NavLinks (use usePathname) │ │ │ │ - Cart count (reads cookies) │ │ │ │ </Suspense> │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘
Cached Functions with Tags
Each cached function has a tag for targeted invalidation:
// src/app/[channel]/(main)/products/[slug]/page.tsx
async function getProductData(slug: string, channel: string) {
"use cache";
cacheLife("minutes"); // 5 min default TTL
cacheTag(product:${slug}); // Tag for webhook invalidation
return executePublicGraphQL(ProductDetailsDocument, {
variables: { slug, channel },
});
}
Tag Registry
Tag Pattern Used By Invalidated When
product:{slug}
getProductData()
Product updated in Saleor
category:{slug}
getCategoryData()
Category updated
collection:{slug}
getCollectionData() , getFeaturedProducts()
Collection updated
navigation
NavLinks
Menu structure changed
Key Patterns
- Suspense Around Dynamic Content
Any component accessing runtime data must be wrapped in Suspense.
What counts as "dynamic data" (triggers Suspense requirement):
Data Access Why It's Dynamic
cookies()
Per-request
headers()
Per-request
searchParams
URL-dependent
usePathname()
Client-side routing
useParams()
Client-side routing
Date.now()
Time-dependent
Server Actions Form submissions
cache: "no-cache" fetches Always fresh
// Layout wraps children in Suspense <main className="flex-1"> <Suspense>{props.children}</Suspense> </main>
// Header wraps NavLinks in Suspense (uses usePathname for active state) <Suspense fallback={<NavLinksSkeleton />}> <NavLinks channel={channel} /> </Suspense>
- Public vs Authenticated Queries
Two explicit GraphQL helpers:
-
executePublicGraphQL
-
Safe inside "use cache" (no cookies needed)
-
executeAuthenticatedGraphQL
-
NOT safe inside "use cache" (requires cookies)
import { executePublicGraphQL, executeAuthenticatedGraphQL } from "@/lib/graphql";
// ✅ Public data - safe inside "use cache" async function getProductData(slug: string, channel: string) { "use cache"; return executePublicGraphQL(ProductDetailsDocument, { variables: { slug, channel }, }); }
// ✅ User data - NOT inside "use cache" (requires cookies) const { me } = await executeAuthenticatedGraphQL(CurrentUserDocument, { cache: "no-cache", });
- Don't Use searchParams Inside "use cache"
// ❌ BAD - searchParams is runtime data export async function generateMetadata(props) { "use cache"; const searchParams = await props.searchParams; // Error! }
// ✅ GOOD - Only access params (becomes cache key) export async function generateMetadata(props) { "use cache"; const params = await props.params; // OK }
// ✅ GOOD - Access searchParams outside cache scope export async function generateMetadata(props) { const searchParams = await props.searchParams; // No "use cache" }
- CSS Order Pattern for Mixed Static/Dynamic Layouts
When you need dynamic content to appear above static content visually, use CSS order :
// PDP: Category (dynamic) appears above Product Name (static) <div className="flex flex-col gap-3"> {/* Static shell - renders first but order:2 */} <h1 className="order-2">{product.name}</h1>
{/* Dynamic - streams in, order:1 appears above h1 */}
<Suspense fallback={<Skeleton className="order-1" />}>
<VariantSection /> {/* Contains order-1 and order-3 elements */}
</Suspense>
{/* Static - order:4 appears last */}
<div className="order-4">
<ProductAttributes />
</div>
</div>
Visual result:
- Category + Sale badge (dynamic, order-1)
- Product Name (static, order-2)
- Variant selectors (dynamic, order-3)
- Product details (static, order-4)
This keeps <h1> in the static shell for SEO while allowing dynamic content to appear above it.
- GraphQL Auth Defaults
Two explicit GraphQL helpers ensure you always know what data access level you're using:
-
executePublicGraphQL
-
Public queries only (products, menus, categories)
-
executeAuthenticatedGraphQL
-
Requires user session cookies (checkout, user data)
This ensures:
-
Only publicly visible products are fetched
-
No user cookies in cache scope (safe for "use cache" )
-
No "Signature has expired" errors on public pages
import { executePublicGraphQL, executeAuthenticatedGraphQL } from "@/lib/graphql";
// ✅ Public data (menus, products) - no auth, only public data const menu = await executePublicGraphQL(MenuDocument, { variables: { slug: "footer" }, });
// ✅ User data - requires session cookies let user = null; try { const result = await executeAuthenticatedGraphQL(CurrentUserDocument, { cache: "no-cache", }); user = result.me; } catch { // Expired token = treat as not logged in }
// ✅ Checkout/cart - requires session cookies await executeAuthenticatedGraphQL(CheckoutAddLineDocument, { variables: { id: checkoutId, productVariantId: variantId }, cache: "no-cache", });
// ✅ App token (server-side only) - explicit header
const channels = await executePublicGraphQL(ChannelsListDocument, {
headers: {
Authorization: Bearer ${process.env.SALEOR_APP_TOKEN},
},
});
Cache Invalidation
Automatic via Webhooks (Recommended)
When configured, Saleor sends webhooks on data changes, triggering instant invalidation.
Setup in Saleor Dashboard:
-
Go to Configuration → Webhooks
-
Create webhook pointing to: https://your-site.com/api/revalidate
-
Subscribe to events:
-
PRODUCT_CREATED , PRODUCT_UPDATED , PRODUCT_DELETED
-
CATEGORY_CREATED , CATEGORY_UPDATED , CATEGORY_DELETED
-
COLLECTION_CREATED , COLLECTION_UPDATED , COLLECTION_DELETED
-
Copy the secret key to SALEOR_WEBHOOK_SECRET env var
What happens on webhook:
// Product update webhook triggers:
revalidateTag(product:${slug}, "minutes"); // Invalidates "use cache" data
revalidatePath(/channel/products/${slug}); // Invalidates ISR page
Manual Invalidation
Invalidate a specific product (both tag and path)
Invalidate just the cached function data
curl "https://store.com/api/revalidate?secret=xxx&tag=product:blue-hoodie"
Invalidate navigation (uses "hours" profile)
curl "https://store.com/api/revalidate?secret=xxx&tag=navigation&profile=hours"
No Webhooks? TTL Takes Over
Data Default TTL
Products 5 minutes
Categories 5 minutes
Collections 5 minutes
Navigation 1 hour
Environment Variables
Cache invalidation
REVALIDATE_SECRET=your-secret # Manual revalidation (GET requests) SALEOR_WEBHOOK_SECRET=webhook-hmac # Saleor webhook HMAC verification
Debugging Stale Content
Checklist
Is the webhook configured?
-
Check Saleor Dashboard → Webhooks → Deliveries
Did the webhook fire?
-
Check server logs for [Revalidate] entries
Is the tag correct?
-
Product slugs must match exactly: product:blue-hoodie
Force manual revalidation:
curl "https://store.com/api/revalidate?secret=xxx&tag=product:my-product"
Check browser cache:
- Hard refresh: Cmd+Shift+R / Ctrl+Shift+R
Anti-patterns
❌ Don't use cache: "no-cache" for display pages - Destroys performance
❌ Don't skip webhook setup in production - Users see stale prices
❌ Don't access cookies/searchParams inside "use cache"
- Will error
❌ Don't use executeAuthenticatedGraphQL inside "use cache"
- Requires cookies
❌ Don't expose REVALIDATE_SECRET
- Keep it server-side only
Disabling Cache Components
If you need to rollback to standard ISR caching:
Step 1: Disable in Config
// next.config.js const config = { cacheComponents: false, // or comment out entirely };
Step 2: Remove Cache Directives
Remove "use cache" , cacheLife() , and cacheTag() from these files:
File What to Remove
src/app/[channel]/(main)/products/[slug]/page.tsx
getProductData() cache directives
src/app/[channel]/(main)/categories/[slug]/page.tsx
getCategoryData() cache directives
src/app/[channel]/(main)/collections/[slug]/page.tsx
getCollectionData() cache directives
src/app/[channel]/(main)/page.tsx
getFeaturedProducts() cache directives
src/ui/components/nav/components/nav-links.tsx
Navigation cache directives
Step 3: Update Revalidation
// src/app/api/revalidate/route.ts
// Change from:
revalidateTag(product:${slug}, "minutes");
// To:
revalidateTag(product:${slug}); // Remove second argument
What You Can Keep
-
Suspense boundaries - Still useful for loading states
-
CSS order layout - Pure CSS, no impact
-
executeAuthenticatedGraphQL
-
Good separation regardless
-
ISR via revalidate option - Works as fallback
Files Reference
File Purpose
src/app/api/revalidate/route.ts
Webhook endpoint and manual revalidation
src/app/[channel]/(main)/products/[slug]/page.tsx
PDP with "use cache"
src/app/[channel]/(main)/categories/[slug]/page.tsx
Category with "use cache"
src/app/[channel]/(main)/collections/[slug]/page.tsx
Collection with "use cache"
src/app/[channel]/(main)/page.tsx
Homepage with "use cache"
src/ui/components/pdp/variant-section-dynamic.tsx
Dynamic variant section
src/ui/components/header.tsx
Header with Suspense boundaries
src/lib/checkout.ts
Cart operations (always fresh)
next.config.js
cacheComponents: true