Overview
Next.js App Router collapses rendering strategy into a single API: fetch() with cache options. The strategy is determined per-request-per-segment based on how data is fetched, not by which function you export. Understanding the cache semantics — and when Next.js opts a segment out of static rendering — is what separates informed architecture from trial and error.
Architecture
RENDERING STRATEGIES
════════════════════════════════════════════════════════
STATIC (SSG equivalent) Default for App Router
├── Rendered at build time No dynamic functions called
├── HTML cached on CDN edge fetch() with cache: 'force-cache'
├── Fastest possible TTFB or no revalidation options
└── Use for: marketing, docs, blogs
DYNAMIC (SSR equivalent)
├── Rendered on every request cookies(), headers(), searchParams used
├── Cannot be cached at edge OR fetch() with cache: 'no-store'
├── Fresh data guaranteed OR dynamic = 'force-dynamic' export
└── Use for: dashboards, personalized content
INCREMENTAL STATIC REGENERATION (ISR)
├── Statically generated at build
├── Regenerated in background fetch({ next: { revalidate: 60 } })
│ after revalidate period or export const revalidate = 60
├── Stale-while-revalidate behavior first user after expiry gets stale
└── Use for: product pages, news feeds, semi-dynamic content
PARTIAL PRERENDERING (PPR — Next.js 14+)
├── Static shell served instantly <Suspense> boundaries create PPR holes
├── Dynamic holes stream in Each Suspense = separate dynamic island
├── Best of SSG + SSR No trade-off between static shell + dynamic data
└── Use for: e-commerce (static product + dynamic price/inventory)
FETCH CACHE OPTIONS (Next.js App Router)
════════════════════════════════════════════════════════
fetch(url, { cache: 'force-cache' }) → static (cached forever or until revalidated)
fetch(url, { cache: 'no-store' }) → dynamic (never cached, every request fresh)
fetch(url, { next: { revalidate: 60 }}) → ISR (cached 60s, then background refresh)
fetch(url, { next: { tags: ['products'] }}) → tag-based revalidation via revalidateTag()
WHAT FORCES DYNAMIC RENDERING:
• cookies() or headers() called in Server Component
• searchParams prop used in page.tsx
• Dynamic segment with generateStaticParams() not covering all cases
• fetch({ cache: 'no-store' }) anywhere in the render tree
Technical Implementation
Static with On-Demand Revalidation
// app/products/[slug]/page.tsx — statically generated at build
export async function generateStaticParams() {
const products = await getProducts();
return products.map(p => ({ slug: p.slug })); // pre-generate these pages
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
// Cached forever — regenerated only via revalidateTag('products')
const product = await fetch(`https://api.example.com/products/${params.slug}`, {
next: { tags: [`product:${params.slug}`, 'products'] },
}).then(r => r.json());
return <ProductDetail product={product} />;
}
// API route — call from CMS webhook or admin action to bust specific pages
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(tag); // invalidates all pages tagged with this tag
return Response.json({ revalidated: true, tag });
}
ISR with Time-Based Revalidation
// Segment-level revalidation: applies to all fetches in this route
export const revalidate = 300; // 5 minutes — stale-while-revalidate
export default async function BlogPage() {
const posts = await fetch('https://cms.example.com/posts', {
// Inherits segment revalidate (300s), or override per-fetch:
next: { revalidate: 60 }, // per-fetch override: 1 minute
}).then(r => r.json());
return (
<div>
{posts.map((post: Post) => <PostCard key={post.id} post={post} />)}
</div>
);
}
Partial Prerendering (PPR)
// next.config.ts — enable PPR (Next.js 14 experimental, 15 stable)
const config = {
experimental: { ppr: true },
};
// app/shop/[id]/page.tsx — PPR in action
import { Suspense } from 'react';
// Static shell: product name, description, images → built at deploy time
// Dynamic holes: price, stock, personalized CTA → stream from server on request
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // static (cacheable)
return (
<div>
{/* Static content: renders at build time, served from CDN instantly */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
{/* Dynamic hole: Suspense = PPR boundary, streams on each request */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} /> {/* reads cookies for user pricing */}
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<LiveStock productId={params.id} /> {/* never cached */}
</Suspense>
</div>
);
}
// DynamicPrice is a Server Component — but it reads cookies → forces dynamic
async function DynamicPrice({ productId }: { productId: string }) {
const userTier = cookies().get('user-tier')?.value ?? 'standard';
const price = await getPrice(productId, userTier); // cache: 'no-store'
return <PriceDisplay price={price} tier={userTier} />;
}
Interview Preparation
Q: How does the App Router determine whether to statically or dynamically render a page?
Next.js statically renders a route segment by default unless it detects a "dynamic signal." Dynamic signals include: calling cookies() or headers() inside the segment's render tree, accessing searchParams prop in page.tsx, using fetch({ cache: 'no-store' }), or exporting export const dynamic = 'force-dynamic'. The presence of any dynamic signal forces the entire segment to dynamic rendering (unless PPR is enabled, where Suspense boundaries isolate dynamic islands). This means a Server Component that calls cookies() deep in the render tree makes the entire page dynamic — even if most of the data is cacheable.
Q: What is the difference between ISR revalidate and on-demand revalidation?
Time-based ISR (revalidate: 60) uses stale-while-revalidate semantics: after 60 seconds, the next request gets the stale cached page while Next.js regenerates a fresh version in the background. Subsequent requests get the new version. On-demand revalidation (revalidateTag / revalidatePath) invalidates the cache immediately when you call it — typically from a webhook when CMS content changes. On-demand is better for content accuracy (page updates within seconds of CMS change, not 60 seconds later). Time-based is simpler (no webhook infrastructure needed). Use both together: short revalidate as a safety net, on-demand revalidation for immediate updates.
Q: When would you choose no-store over force-cache for data fetching?
force-cache (default): data is cached and served from cache on subsequent requests. Use for data that changes infrequently and where freshness is not critical — product catalog, documentation, blog posts. no-store: data is never cached, always fetched fresh. Use for: user-specific data (cart, notifications, account info), real-time data (stock prices, live scores), or any data where stale results would be wrong or harmful. Financial data, inventory counts, session-dependent content — all no-store. Rule of thumb: if multiple different users should see different data for the same URL, it must be no-store (or use cookies to vary the cache).
Learning Resources
- Next.js — Data Fetching
- Next.js — Caching — full cache layer docs
- Next.js — Partial Prerendering
- Vercel — ISR explained