Default mental model
In the App Router, fetch in Server Components can be cached and deduplicated. That is great for marketing pages and terrible for per-user dashboards if you leave defaults wrong. I treat every fetch as needing an explicit caching policy in code review.
Rule of thumb I use: public, slow-changing content gets cache plus revalidate. User-specific or money-adjacent data gets cache no-store or export const dynamic equals force-dynamic. Mixed pages get segment config plus tagged revalidation.
Next version upgrades sometimes change default caching behavior. After one upgrade, a previously dynamic route started caching. We now assert cache headers in an integration test for account routes so upgrades cannot silently flip policy.
Static-ish content with time-based revalidation
Blog indexes and pricing pages tolerate slight staleness. revalidate 60 means ISR-style refresh at most once per minute per deployment semantics. We saw TTFB drop from about 400ms to about 80ms on a docs landing page after caching the CMS fetch.
Do not use a 60-second window for content that editors expect to appear instantly. Either shorten revalidate for that segment or use on-demand tags. Mixing feels-live marketing copy with a 5-minute revalidate creates trust issues with the content team.
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://cms.example.com/posts', {
next: { revalidate: 60, tags: ['posts'] },
});
if (!res.ok) throw new Error('Failed to load posts');
return res.json();
}
export default async function BlogIndex() {
const posts = await getPosts();
return (
<ul>
{posts.map((p) => (
<li key={p.slug}>{p.title}</li>
))}
</ul>
);
}
On-demand revalidation with tags
When editors publish, waiting 60s is awkward. We tag fetches and call revalidateTag posts from a webhook Server Action or route handler after CMS publish. That cut where-is-my-post support pings to near zero.
Protect the webhook with a shared secret and rate limiting. An open revalidate endpoint is a cheap DoS against your origin and cache.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
revalidateTag('posts');
return NextResponse.json({ revalidated: true });
}
Dynamic user data
Account pages that read session cookies must not serve another user's cache entry. I use cache no-store on those fetches and read auth in the server component. Alternatively set dynamic to force-dynamic at the route segment.
The inventory bug I mentioned: we had cached fetch to /api/stock without realizing the route was shared. Two users saw each other's warehouse counts briefly. Fix was no-store plus moving stock behind an authenticated route handler.
After the fix we added a regression test that fetches the same route as two sessions and asserts different bodies. Cache bugs are silent without tests like that.
async function getBalance(userId: string) {
const res = await fetch(`${process.env.API_URL}/balance/${userId}`, {
cache: 'no-store',
headers: { Authorization: `Bearer ${process.env.INTERNAL_TOKEN}` },
});
return res.json();
}
Server Actions for mutations
I use Server Actions for form posts that do not need a separate REST surface: update profile, create comment, toggle flag. Keep them thin — validate with Zod, call a service function, revalidate tags, redirect.
Do not put complex business logic only inside the action file if other entry points such as API routes and jobs need the same rules. Extract a shared service so authorization checks stay consistent.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { updateDisplayName } from '@/server/users';
const schema = z.object({
displayName: z.string().trim().min(2).max(80),
});
export async function saveProfile(formData: FormData) {
const parsed = schema.safeParse({
displayName: formData.get('displayName'),
});
if (!parsed.success) return { error: 'invalid_name' };
await updateDisplayName(parsed.data.displayName);
revalidatePath('/settings/profile');
return { ok: true };
}
Streaming and Suspense boundaries
For pages with a fast shell and a slow panel I wrap the slow Server Component in Suspense so the shell streams first. Users see navigation chrome and a skeleton while the slow fetch completes. That improved perceived performance even when total time-to-full-content stayed similar.
I avoid putting the entire page behind one slow await at the top of the tree. A single blocking await getEverything defeats streaming. Split fetches by section. Error boundaries around streamed sections stop one failing widget from blanking the whole route — we learned that when recommendations 500ed and took the product page down.
Auth cookies and the cache
Reading cookies in a layout opts routes into dynamic rendering. That is correct for dashboards and easy to forget on a shared root layout that also wraps marketing pages. We split layouts so public marketing stays statically cacheable while app routes stay dynamic.
If you must read cookies deep in a tree, be intentional about which segments inherit dynamic behavior. Accidental dynamic marketing pages raised origin load and erased the TTFB wins from ISR.
// app/(marketing)/layout.tsx — no cookies(), cache-friendly
export default function MarketingLayout({ children }) {
return <div className="marketing">{children}</div>;
}
// app/(app)/layout.tsx — auth-aware, dynamic
import { cookies } from 'next/headers';
export default async function AppLayout({ children }) {
const session = cookies().get('access_token');
return <AppShell signedIn={Boolean(session)}>{children}</AppShell>;
}
Client fetch versus Server Components
I keep initial data on the server when SEO or first paint matters. Client components use SWR or React Query for interactive polling filters. Duplicating the same REST call on server and client without a shared contract caused drift — we now generate types from OpenAPI and share the path helpers.
Server Actions work well for mutations with progressive enhancement. I still validate on the server with Zod; never trust the action payload shape alone.
- Be explicit: revalidate, no-store, or force-dynamic
- Tag cached fetches for webhook-driven updates
- Never cache personalized responses under a shared key
- Prefer Server Components for first paint; client libraries for live interactivity
- Protect revalidate endpoints with a secret
- Regression-test personalized routes across two sessions
Debugging why is this stale
Check whether you are looking at the Full Route Cache, the Data Cache, or the browser cache. Next.js Dev overlay and logging fetch cache hits in development help. In one case the CDN in front of us cached HTML despite no-store on fetch — we had to set Cache-Control on the response headers from the route.
Document the caching choice in a one-line comment above non-obvious fetches. My incident checklist order: response headers from the browser, Next fetch cache tags, CDN purge, whether the deploy rolled out to all regions. Skipping the CDN step wasted an hour once. When in doubt I draw three boxes — Data Cache, Full Route Cache, CDN — and mark where the stale bytes live.
Key takeaways
- Treat every App Router fetch as needing an explicit cache policy.
- Use time-based revalidate for public content; tags for editor-driven updates.
- Personalized and financial data should use no-store or force-dynamic.
- Server Components for initial HTML; client caches for interactive freshness.
- Stale bugs can live in Next caches, the browser, or your CDN — check all three.
- Validate Server Action input with Zod and share business logic with other entry points.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.