Practical Caching Recipes for Next.js (App Router)

Practical Caching Recipes for Next.js (App Router)

Caching in the App Router is simple once you know the few primitives that matter. This guide gives copy‑pasteable snippets you can drop into a fresh Next.js app and get real wins today.

We’ll cover:

  • Segment‑level caching (static vs dynamic)
  • fetch caching and ISR
  • Tag‑based revalidation (revalidateTag)
  • Route Handler cache headers
  • Server Actions + revalidation
  • Client‑side SWR for UX polish

1) Segment‑level caching: the baseline

Control how a route renders by default. Add these exports at the top of a page/layout.

// app/products/page.tsx
export const revalidate = 60;       // ISR: re-render at most every 60s
// export const dynamic = 'force-static'; // full static (no per-request data)
// export const dynamic = 'force-dynamic'; // fully dynamic (no caching)

export default async function ProductsPage() {
  const products = await getTopProducts();
  return <List items={products} />;
}

Tips:

  • Prefer revalidate for content that changes occasionally.
  • Use force-dynamic for user‑specific views or rapidly changing data.

2) fetch caching: per‑request control

fetch grows superpowers in the App Router: you can set ISR per request and attach cache tags.

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    // Revalidate cached response every 60s
    next: { revalidate: 60, tags: ['products'] },
    // cache: 'force-cache' // default when revalidate provided
  }).then(r => r.json());

  return <List items={products} />;
}

Notes:

  • next: { revalidate } enables ISR even for dynamic data sources.
  • Add tags to enable precise invalidation later.

3) Tag‑based revalidation (webhooks or mutations)

Invalidate cached content tied to a tag from any server context.

// app/api/revalidate/products/route.ts
import { revalidateTag } from 'next/cache';

export async function POST() {
  revalidateTag('products'); // blow away caches tagged "products"
  return new Response('ok');
}

Pair with an upstream webhook (e.g., CMS → POST to /api/revalidate/products).

Server Action variant:

// app/(admin)/actions.ts
'use server';
import { revalidateTag } from 'next/cache';

export async function createProduct(formData: FormData) {
  // ... persist product ...
  revalidateTag('products'); // refresh list views
}

4) Route Handlers with explicit HTTP cache headers

Use HTTP headers for fine‑grained control (and CDNs love these).

// app/api/search/route.ts
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const q = searchParams.get('q') ?? '';
  const data = await search(q);

  // Cache popular empty query; bypass cache for custom queries
  const headers = new Headers({ 'content-type': 'application/json' });
  if (q === '') headers.set('cache-control', 's-maxage=60, stale-while-revalidate=600');
  else headers.set('cache-control', 'no-store');

  return new Response(JSON.stringify(data), { headers });
}

Also handy:

  • fetch(url, { next: { revalidate } }) inside Route Handlers
  • export const runtime = 'edge' to move handler to the Edge

5) Mutation → revalidate (happy users, fresh pages)

After a write, refresh the right pages or data.

// app/(admin)/products/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function updatePrice(id: string, price: number) {
  await savePrice(id, price);
  revalidateTag('products');        // invalidate list caches
  revalidatePath('/products');      // and the products page route
}

Pick one or combine both depending on what you cache.


6) Client‑side polish with SWR

Even with ISR, client hints make the UI feel instant.

// app/products/_components/Price.tsx
'use client';
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(r => r.json());

export function Price({ id }: { id: string }) {
  const { data } = useSWR(`/api/price?id=${id}`, fetcher, {
    refreshInterval: 30_000, // keep it fresh
  });
  if (!data) return <span>$—</span>;
  return <span>${data.price}</span>;
}

Pair this with a cached /api/price Route Handler.


7) Prefetching and “stale‑while‑revalidate” mental model

  • Links prefetch by default in viewport; keep your pages cacheable to benefit.
  • Think: “serve old data fast, revalidate in background, then refresh.” That’s ISR in practice.

Mermaid mental model:

sequenceDiagram
  participant U as User
  participant C as Cache
  participant S as Server
  U->>C: Request /products
  alt cached and fresh
    C-->>U: Serve cached HTML/data
  else cached but stale
    C-->>U: Serve stale data fast
    C->>S: Trigger revalidation
    S-->>C: Store fresh result
  end

8) Edge vs Node: tiny knobs

// app/api/geo/route.ts
export const runtime = 'edge'; // ultra low‑latency

export async function GET() {
  return Response.json({ ok: true }, {
    headers: { 'cache-control': 's-maxage=30, stale-while-revalidate=300' },
  });
}

Edge is great for fast, cacheable reads. Keep heavy compute and big SDKs on Node runtime.


9) Common pitfalls (and quick fixes)

  • User‑specific pages: mark as dynamic (force-dynamic) or set cache: 'no-store' on fetch.
  • Mixed models: if you add cookies()/headers() in a segment, it becomes dynamic. Move stateful logic to a child or Route Handler.
  • Tag mismatch: revalidateTag('x') only purges fetch(..., { next: { tags: ['x'] } }) calls.
  • Static assets: next/image is already optimized; avoid manual cache busting unless you must.

10) Quick starter you can paste

// app/blog/page.tsx
export const revalidate = 120; // 2 minutes ISR

export default async function Blog() {
  const posts = await fetch('https://api.example.com/blog', {
    next: { revalidate: 120, tags: ['blog'] },
  }).then(r => r.json());
  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
// app/api/revalidate/blog/route.ts
import { revalidateTag } from 'next/cache';
export async function POST() {
  revalidateTag('blog');
  return new Response('ok');
}

That’s it—drop in, ship, and enjoy fast pages that stay fresh.

Read more