Rendering strategies in Next.js: choosing the right approach for every route
The speed of your Next.js application depends less on how much JavaScript you ship and more on when content reaches the browser. Rendering strategies in Next.js let you decide per route whether to precompile at build time, generate on demand, revalidate periodically, or stream dynamic content—and that granularity is what separates sluggish sites from snappy ones. In this post, I'll walk through every strategy available in the App Router, show you when to use each one, and share the patterns I've used across e-commerce, CMS, and trading platforms.
Choose the right strategy per page
Every route you create should answer one question: When should this content be ready? Your answer determines which rendering strategy wins. Let me break down the main options and their real-world homes.
Static Site Generation (SSG)
Strategy: Build once, serve everywhere. Next.js precompiles your page at build time and serves the same HTML to every visitor.
Best for: Marketing sites, documentation, blog posts, landing pages, and any content that doesn't change between deploys.
Example scenario: Your company's /about, /pricing, and /features pages. Build them once, cache them forever on a CDN. Response time: instant.
// app/about/page.tsx — static by default
export default function About() {
return (
<>
<h1>About Us</h1>
<p>Founded in 2020, we build...</p>
</>
);
}
No dynamic functions, no fetch() with cache busting, no cookies() calls. This page is static. Vercel will generate it once and replicate it across 280+ edge locations. Win.
Incremental Static Regeneration (ISR) with revalidate
Strategy: Precompile at build time, then refresh stale content on a schedule or on demand.
Best for: Product catalogs, CMS content, blog archives, or anything that updates infrequently but can't wait until the next deploy.
Example scenario: An e-commerce site with 10,000 SKUs. You can't precompile every product detail page at build time (it'd take 20 minutes), but you also can't render each one dynamically on request (too slow). ISR lets you generate product pages on first request, then refresh them every 3,600 seconds.
// app/products/[id]/page.tsx
export const revalidate = 3600; // revalidate every hour
export async function generateStaticParams() {
// Precompile the 100 most popular products at build time
const topProducts = await fetch('https://api.example.com/top-products').then(r => r.json());
return topProducts.map((p) => ({ id: p.id.toString() }));
}
export default async function ProductPage({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { revalidate: 3600 }
}).then(r => r.json());
return (
<>
<h1>{product.name}</h1>
<p>${product.price}</p>
</>
);
}
The first user to hit /products/999 waits for a fresh render. Everyone after that gets the cached version for an hour. When the clock strikes 3601 seconds, the next request triggers a rebuild in the background. Old visitors don't suffer.
Dynamic Server-Side Rendering (SSR)
Strategy: Render on every request. No caching, no precompilation.
Best for: Personalized dashboards, account pages, real-time data, request-specific content like search results or filtered views.
Example scenario: A user's /dashboard. It shows their account balance, recent transactions, and pending alerts—all tied to their identity. You can't precompile this, and you can't cache it. Every request is fresh.
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export default async function Dashboard() {
const session = await getSession(); // requires cookies, headers, or auth context
const userData = await fetch(`https://api.example.com/users/${session.id}`, {
headers: { Authorization: `Bearer ${session.token}` }
}).then(r => r.json());
return (
<>
<h1>Welcome, {userData.name}</h1>
<p>Balance: ${userData.balance}</p>
</>
);
}
Every visitor gets a fresh render, complete with their fresh data. No caching. No surprises. Slower than static, but correct.
Partial Prerendering (PPR)
Strategy: Combine the best of both worlds. Precompile a static shell instantly, stream dynamic content in the background.
Best for: Pages that are mostly static but have one or two personalized sections. Think product pages with a user-specific "Add to cart" button, or blog posts with personalized recommendations at the bottom.
Example scenario: A trading dashboard. The charts and market data are the same for everyone, but your portfolio and P&L are personal. PPR precompiles the static shell in 50ms, then streams your portfolio data in the next 200ms. Total time to interactive: ~250ms instead of 2000ms.
// app/markets/[symbol]/page.tsx
import { Suspense } from 'react';
export const experimental_ppr = true; // enable PPR
export const revalidate = 60; // static shell revalidates every 60s
async function PersonalizedPortfolio() {
const session = await getSession(); // dynamic = makes this section dynamic
const holdings = await fetch(`https://api.example.com/portfolio/${session.id}`, {
next: { revalidate: 10 } // fresher than the shell
}).then(r => r.json());
return (
<div>
<h2>Your Holdings</h2>
<p>{holdings.length} positions</p>
</div>
);
}
export default async function MarketPage({ params }) {
const market = await fetch(`https://api.example.com/markets/${params.symbol}`, {
next: { revalidate: 60 }
}).then(r => r.json());
return (
<>
<h1>{market.name}</h1>
<p>Price: ${market.price}</p>
<Suspense fallback={<div>Loading your portfolio...</div>}>
<PersonalizedPortfolio />
</Suspense>
</>
);
}
Static content arrives instantly. Dynamic content streams in via a Suspense boundary. Visitors see the full page almost immediately, then their personal data fills in. Far better experience than waiting for the whole page to render.
Static is the default goal
When I start building a new route, I assume it should be static. The fastest response is one already cached. A static page served from a CDN takes 10–50ms. A dynamically rendered page takes 500–2000ms. That's not a small difference; it compounds across thousands of requests.
Next.js agrees. By default, every route in the App Router is static unless you opt out. No configuration needed. Just write your component and deploy it.
The moment you introduce a dynamic input—a query parameter, a cookie, a header—Next.js marks the route dynamic and renders on every request. That's not a bug; it's a safety feature. You don't want to cache personalized content.
But it's a trade-off. If you need search results from a query string, you're no longer static. If you need user preferences from cookies, you're no longer static. Think ahead about what you're building and choose accordingly.
Dynamic APIs opt out of caching
This is the critical rule I'd hammer home to any team: calling cookies(), headers(), or searchParams in a Server Component automatically makes that route dynamic. No way around it.
Why? Because these APIs are request-specific. They change per user, per browser, per query. You can't precompile something that depends on a cookie. You can't cache something that depends on a search string.
Let me show you the difference:
// Static route — no dynamic APIs
export default async function StaticPage() {
const data = await fetch('https://api.example.com/global-data', {
next: { revalidate: 3600 }
}).then(r => r.json());
return <div>{data.message}</div>;
}
// Dynamic route — uses cookies()
import { cookies } from 'next/headers';
export default async function DynamicPage() {
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value || 'light';
return <div style={{ background: theme === 'dark' ? '#000' : '#fff' }}>...</div>;
}
The first one? Static. Precompiled once, served to everyone. The second one? Dynamic. Rendered per request, because the output depends on a cookie that changes per visitor.
Same goes for searchParams. If you filter a product list by category, you're reading a dynamic value. You can't precompile /products?category=shoes. You need to render it on request.
// Dynamic because of searchParams
export default async function ProductList({ searchParams }) {
const category = searchParams.category || 'all';
const products = await fetch(`https://api.example.com/products?cat=${category}`).then(r => r.json());
return (
<>
<h1>{category} Products</h1>
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
</>
);
}
Every unique query string triggers a new render. You can cache the results via a database query cache or Redis, but Next.js itself won't precompile the route.
PPR is the future
Partial Prerendering is the newest strategy, and it's a game-changer for high-traffic sites. It lets you have your cake and eat it too: static performance for most of the page, dynamic freshness where it matters.
The mental model is simple. Your page has two layers: a static shell and dynamic holes. The shell precompiles at build time. The holes render on request and stream in via React Server Components. Visitors see the shell instantly and the holes fill in as data arrives.
I've tested this on a ride-hailing app's driver homepage. The map, driver stats, and navigation were static (refreshed every 60 seconds). The live ride count and earnings were dynamic (refreshed every 10 seconds). PPR meant drivers saw the full page in 150ms instead of waiting 2–3 seconds for the entire page to fetch live data.
// app/driver/dashboard/page.tsx
import { Suspense } from 'react';
export const experimental_ppr = true;
export const revalidate = 60;
async function LiveStats() {
const session = await getSession();
const stats = await fetch(`https://api.example.com/drivers/${session.id}/live-stats`, {
next: { revalidate: 10 }
}).then(r => r.json());
return (
<div>
<p>Active Rides: {stats.activeRides}</p>
<p>Today's Earnings: ${stats.earnings}</p>
</div>
);
}
export default async function DriverDashboard() {
const staticMap = await fetch('https://maps.example.com/snapshot', {
next: { revalidate: 60 }
}).then(r => r.json());
return (
<>
<h1>Driver Dashboard</h1>
<div>{staticMap.html}</div> {/* Static, instant */}
<Suspense fallback={<div>Loading live stats...</div>}>
<LiveStats /> {/* Dynamic, streams in */}
</Suspense>
</>
);
}
Enable PPR with experimental_ppr = true (it's stable enough for production if your hosting supports it, which Vercel does). Wrap dynamic sections in Suspense. Let Next.js handle the rest.
Common mistakes
Mistake 1: Assuming fetch() is always cached. It's not. If you don't pass a next option, data is cached for the lifetime of the build. If you pass { next: { revalidate: 0 } }, it's dynamic. Read the docs for each fetch.
Mistake 2: Using searchParams without thinking about scale. If you have 100,000 unique query string combinations, you'll render 100,000 variations. That's fine for a small number of filters, but watch out for user-generated query strings or sorting/pagination that explodes your render count.
Mistake 3: Forgetting that headers() and cookies() make the route dynamic. I've seen teams cache pages that call headers() and then wonder why user-agent detection isn't working. Read the route once, ensure you understand which APIs are dynamic, then decide if that's intentional.
Mistake 4: Not using ISR for often-stale data. If your CMS content updates every hour, don't render it on every request (wasteful). Set revalidate to 3600 and let ISR refresh the cache in the background.
Key takeaways
- Static is the default. Every route you write should aim for static unless a dynamic requirement forces otherwise.
- Choose per route. Marketing pages: static. Product details: ISR. Dashboards: dynamic. Hybrid: PPR.
- Dynamic APIs opt out. Calling
cookies(),headers(), orsearchParamsmakes a route dynamic. Know what you're choosing. - ISR is the middle ground. Precompile + periodic refresh for content that changes between deploys but not constantly.
- PPR combines static speed with dynamic freshness. Prerender the shell, stream the holes. Best experience for mostly-static pages with one or two dynamic sections.
- Test with real data. Build each route with production-scale data. A page that's fast with 10 products might struggle with 10,000.






Comments
Loading comments…