When people say “Next.js is fast,” they usually mean it caches smartly. If you learn how that cache works—and when to refresh it—you can take your app from pretty quick to feels instant.
This post is a practical guide I wish I had on day one. No fluff—just patterns I use in production.
0) A 60-second mental model
- Static (SSG/ISR): HTML is generated ahead of time on the server/CDN.
- Dynamic (SSR): HTML is generated on every request.
- Revalidation: tell Next.js when to refresh previously cached HTML/data.
-
CDN: respects HTTP
Cache-Control
(e.g.,s-maxage
,stale-while-revalidate
).
You mix these per route segment and per fetch call.
1) Route segment config: opt into static or dynamic
// app/dashboard/page.tsx
export const dynamic = 'force-static'; // or 'force-dynamic'
export const revalidate = 60; // ISR: re-generate at most once a minute
When to use
-
force-static
for content that changes rarely (blogs, docs). -
revalidate
for content that changes occasionally (marketing stats, pricing).
Gotcha: any use of cookies()
, headers()
, or searchParams
may switch the route to dynamic. If you only need them client-side, move that logic to a client component.
2) Per-fetch caching: granular control
// Static + revalidate every 5 minutes
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 300 }
});
const products = await res.json();
// Opt out of caching for this call only
const res = await fetch('https://api.example.com/profile', {
cache: 'no-store'
});
// Share cache across routes via tags
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
Rules of thumb
- Use
next.revalidate
for APIs you trust to be stable. - Use
cache: 'no-store'
for per-user/private data. - Use tags when multiple pages depend on the same dataset.
3) Tag-based revalidation (server actions or route handlers)
When your CMS/back-office changes data, nudge Next.js to refresh only what depends on it.
// app/api/revalidate-posts/route.ts
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('posts'); // all fetch() using {tags:['posts']} refresh
return Response.json({ revalidated: true });
}
You can also revalidate a specific path:
import { revalidatePath } from 'next/cache';
// revalidatePath('/blog/my-article');
Pro tip: secure this endpoint (secret header or token) before wiring it to your CMS webhook.
4) Incremental Static Regeneration (ISR) in the App Router
// app/blog/[slug]/page.tsx
export const revalidate = 600; // regenerate at most once every 10 minutes
export async function generateStaticParams() {
const slugs = await getAllSlugs(); // from CMS
return slugs.map((slug) => ({ slug }));
}
Why it’s nice
- First visitor after
revalidate
window triggers regeneration in the background. - Everyone else keeps seeing the cached HTML.
- Zero cron jobs.
5) HTTP cache headers for edge/CDN wins
For dynamic routes you still want CDN help:
// app/api/public-metrics/route.ts
export async function GET() {
const data = await getMetrics();
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300'
}
});
}
-
s-maxage=60
: CDN keeps it for 60s. -
stale-while-revalidate=300
: if stale, serve old copy while a fresh one is fetched.
Test quickly
curl -I https://yourdomain.com/api/public-metrics
Check cache-control
, age
, etc.
6) Client data with SWR/React Query (and when not to)
Use a client cache when:
- You need live-feeling UIs (polling, refetch-on-focus).
- Data is user-specific.
Example with SWR:
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export default function Notifications() {
const { data, error, isLoading } = useSWR('/api/notifications', fetcher, {
refreshInterval: 15_000, // poll every 15s
revalidateOnFocus: true
});
// render states...
}
Pair this with server-side ISR for the public parts of the page to keep TTFB low.
7) Avoid accidental cache busting
- Passing
?timestamp=...
to URLs kills cache. - Reading
cookies()
/headers()
server-side marks the route dynamic. - Using
no-store
on a fetch makes the whole request chain uncached.
If only the client needs personalization, keep server components static and fetch user-specific data client-side.
8) Images, fonts, and assets (quick wins)
-
next/image already adds smart caching; also set a long
s-maxage
via your CDN. -
Fonts: self-host with
next/font
, add<link rel="preconnect" href="https://fonts.gstatic.com" />
only if using external. -
Static assets under
/public
are cacheable—serve withimmutable, max-age=31536000
.
9) A simple checklist
- [ ] Mark static segments and set
revalidate
where sensible. - [ ] Add
next.revalidate
ortags
on fetch calls. - [ ] Expose a secure
/api/revalidate-*
for CMS/webhooks. - [ ] Use CDN headers (
s-maxage
,stale-while-revalidate
) for APIs. - [ ] Keep private/user data
no-store
and fetch on the client if possible. - [ ] Validate with
curl -I
and your CDN cache inspector.
10) TL;DR patterns I keep reusing
-
Marketing pages:
force-static
+revalidate: 3600
. -
Blog: ISR via
revalidate
, plusrevalidateTag('posts')
on publish. -
Dashboards: static shell, client data with SWR; server APIs use
s-maxage
. - Search: dynamic route, no-store fetch, server actions for mutations.
Caching is where Next.js quietly shines. Master these knobs and your app feels instant without hand-rolling complexity.
If you want the same guide as a printable cheat-sheet, say the word—I’ll share a PDF.
Tags
nextjs
react
performance
caching
webdev
tutorial
Top comments (0)