Stop Guessing How to Render Your Next.js Pages (Use This Mental Model Instead)
You've got a new page to build in your Next.js app. You open the docs and immediately face five acronyms: SSG, SSR, ISR, CSR, PPR. You read about all of them, nod along, then pick whichever one you used last time.
I've been there. On a project last year, my team defaulted every route to SSR because "it just works." Our server bill tripled in two months, and half those pages were displaying content that changed once a week. We spent a weekend migrating those routes to ISR and SSG — the infra cost dropped by 60%, and the pages actually loaded faster because they were served from the CDN instead of hitting our origin server every time.
Here's the mental model I wish someone had given me: rendering strategy is a data freshness decision, not a framework decision. Ask "how fresh does this data need to be?" and the answer picks the pattern for you.
Let's walk through all five patterns — what they do, when they fit, and the actual code you'd write.
SSG: Static Site Generation
The question: Does this data change after you deploy?
If no, SSG is your answer. The page gets built once at build time, served as a static HTML file from a CDN, and it's fast every single time.
This is the default in the Next.js App Router. If your component doesn't do anything dynamic, it's already statically generated:
// app/about/page.tsx// This is SSG by default — no special config needed.export default function AboutPage() {return (<main><h1>About Us</h1><p>We build tools for developers who value their time.</p></main>);}
Need to fetch data at build time? Just make your server component async:
// app/blog/page.tsx// Data fetched at build time, page served as static HTML.async function getPosts() {const res = await fetch("<https://api.example.com/posts>", {cache: "force-cache", // explicit: cache indefinitely});return res.json();}export default async function BlogPage() {const posts = await getPosts();return (<ul>{posts.map((post) => (<li key={post.slug}>{post.title}</li>))}</ul>);}
But what about dynamic routes like /blog/[slug]? That's where generateStaticParams comes in. It tells Next.js which paths to pre-render at build time — it's the App Router replacement for the old getStaticPaths:
// app/blog/[slug]/page.tsx// Pre-render all blog posts as static HTML at build time.export async function generateStaticParams() {const posts = await fetch("<https://api.example.com/posts>").then((r) =>r.json(),);return posts.map((post) => ({slug: post.slug, // each object maps to a dynamic segment}));}export default async function BlogPost({params,}: {params: { slug: string };}) {const post = await fetch(`https://api.example.com/posts/${params.slug}`).then((r) => r.json(),);return (<article><h1>{post.title}</h1><div dangerouslySetInnerHTML={{ __html: post.content }} /></article>);}
generateStaticParams runs at build time, generates one static page per slug, and those pages are served instantly from the CDN. If a user hits a slug that wasn't pre-rendered, Next.js renders it on demand and caches it for subsequent requests — you get the best of both worlds.
Use SSG when: Marketing pages, docs, blog posts, changelogs — anything where "stale for a few hours" is a non-issue because you control when it changes.
The trap: Developers underestimate SSG's reach. If your data comes from a CMS and you can trigger a rebuild on publish, SSG covers way more ground than you think. And with generateStaticParams, dynamic routes are no excuse to skip SSG — you can statically generate hundreds of pages at build time.
SSR: Server-Side Rendering
The question: Must this data be fresh on every single request?
SSR renders the page on the server for each request. The user always gets the latest data, but every page load hits your server.
// app/dashboard/page.tsx// Fresh data on every request — no caching.async function getUserDashboard(userId: string) {const res = await fetch(`https://api.example.com/dashboard/${userId}`, {cache: "no-store", // force fresh fetch every request});return res.json();}export default async function DashboardPage() {const session = await getSession();const data = await getUserDashboard(session.userId);return (<main><h1>Welcome back, {data.name}</h1><p>Active projects: {data.projectCount}</p><p>Unread notifications: {data.unreadCount}</p></main>);}
Use SSR when: User dashboards, personalized feeds, real-time inventory, checkout flows — anywhere stale data means a broken experience.
The trap: SSR is the most expensive pattern. Every request runs your server component from scratch. I've seen teams SSR their entire app because they heard "SSR is better for SEO" without realizing SSG and ISR are also server-rendered — just not on every request.
Here's the rule I use: if you can tolerate data being 60 seconds old, you don't need SSR. You need ISR.
ISR: Incremental Static Regeneration
The question: Does this data change, but not right now?
ISR gives you the speed of SSG with a built-in expiry. The page is statically generated, served from cache, and regenerated in the background after a time interval you set.
// app/products/page.tsx// Static page that revalidates every 60 seconds.async function getProducts() {const res = await fetch("<https://api.example.com/products>", {next: { revalidate: 60 }, // regenerate after 60 seconds});return res.json();}export default async function ProductsPage() {const products = await getProducts();return (<ul>{products.map((product) => (<li key={product.id}>{product.name} — ${product.price}</li>))}</ul>);}
You can also trigger revalidation on demand instead of waiting for the timer. Say a product price changes in your CMS — you don't want users seeing stale prices for up to 60 seconds. Set up a webhook in your CMS that hits this API route whenever content is published:
// app/api/revalidate/route.tsimport { revalidatePath } from "next/cache";import { NextRequest, NextResponse } from "next/server";export async function POST(request: NextRequest) {const { path, secret } = await request.json();if (secret !== process.env.REVALIDATION_SECRET) {return NextResponse.json({ error: "Invalid secret" }, { status: 401 });}revalidatePath(path);return NextResponse.json({ revalidated: true });}
The CMS sends a POST with { "path": "/products", "secret": "your-token" }. The route checks the secret (so random callers can't flush your cache), then revalidatePath tells Next.js the cached page is stale — the next visitor gets a freshly rendered version. Think of ISR with a timer as "check for updates every N seconds," and on-demand revalidation as "I'm telling you right now something changed."
Use ISR when: Product listings, blog indexes, pricing pages, leaderboards — data that changes but doesn't need to be real-time.
The trap: Setting revalidate too low (like 1 second) effectively turns ISR into SSR with extra steps. If your revalidation interval is under 10 seconds, ask yourself if you actually need SSR instead.
CSR: Client-Side Rendering
The question: Is this data user-specific, interactive, and irrelevant to SEO?
CSR is the classic React model. The server sends a shell, and the browser fetches data and renders the UI. In the App Router, you opt into this with "use client".
"use client";// app/settings/notifications.tsx// Client-only: user preferences loaded in the browser.import { useState, useEffect } from "react";export default function NotificationSettings() {const [prefs, setPrefs] = useState(null);const [loading, setLoading] = useState(true);useEffect(() => {fetch("/api/me/notifications").then((res) => res.json()).then((data) => {setPrefs(data);setLoading(false);});}, []);if (loading) return <p>Loading preferences...</p>;return (<form><label><inputtype="checkbox"checked={prefs.emailEnabled}onChange={(e) =>setPrefs({ ...prefs, emailEnabled: e.target.checked })}/>Email notifications</label></form>);}
Use CSR when: Settings panels, interactive dashboards with real-time updates, admin tools, anything behind auth that search engines will never see.
The trap: Reaching for "use client" and useEffect for data fetching out of habit. If you're doing this for a page that could be server-rendered, you're shipping unnecessary JavaScript to the browser and giving up the performance benefits you adopted Next.js for in the first place. Before adding "use client", ask: does this component need browser APIs (event handlers, window, localStorage)? If it's just fetching and displaying data, keep it a server component.
PPR: Partial Prerendering
The question: Can I have both — a fast static shell with dynamic holes?
PPR is the newest pattern, and it's the one I'm most excited about. It lets you statically prerender the page layout while streaming in dynamic parts. The user sees a fast static shell instantly, then dynamic content fills in as it resolves.
You mark the dynamic parts with <Suspense>:
// app/product/[id]/page.tsx// Static shell with dynamic price and reviews streamed in.import { Suspense } from "react";async function getProduct(id: string) {const res = await fetch(`https://api.example.com/products/${id}`, {cache: "force-cache",});return res.json();}async function LivePrice({ productId }: { productId: string }) {const res = await fetch(`https://api.example.com/products/${productId}/price`,{ cache: "no-store" },);const { price } = await res.json();return <span className="text-2xl font-bold">${price}</span>;}async function Reviews({ productId }: { productId: string }) {const res = await fetch(`https://api.example.com/products/${productId}/reviews`,{ cache: "no-store" },);const reviews = await res.json();return (<ul>{reviews.map((r) => (<li key={r.id}>{r.text}</li>))}</ul>);}export default async function ProductPage({params,}: {params: { id: string };}) {const product = await getProduct(params.id);return (<main><h1>{product.name}</h1><p>{product.description}</p><Suspense fallback={<span>Loading price...</span>}><LivePrice productId={params.id} /></Suspense><Suspense fallback={<p>Loading reviews...</p>}><Reviews productId={params.id} /></Suspense></main>);}
To enable PPR, set it in your config:
// next.config.tsconst nextConfig = {experimental: {ppr: true,},};export default nextConfig;
Use PPR when: E-commerce product pages, social feeds, any page where the layout is stable but specific sections need live data. It's SSG and SSR on the same page, working together.
The trap: PPR is still experimental as of Next.js 15. Test thoroughly before shipping to production. And don't wrap everything in Suspense — only the parts that are genuinely dynamic. If the whole page is dynamic, you just want SSR.
The Configuration Levers: dynamic and revalidate
You've seen the five patterns. But there's a question you might be asking: how does Next.js actually know which pattern to use?
In many cases, Next.js infers the rendering strategy from your code. If you use cache: "no-store" in a fetch, it knows the page is dynamic. If everything is cacheable, it renders statically. But sometimes you want to be explicit — or override the default. That's what the dynamic and revalidate route segment config exports are for.
Think of them as the manual override switch:
// app/pricing/page.tsx// Force this page to be statically generated, even if Next.js// detects something that looks dynamic.export const dynamic = "force-static";export const revalidate = 3600; // revalidate every hour (ISR)export default async function PricingPage() {const plans = await fetch("<https://api.example.com/plans>").then((r) =>r.json(),);return (<ul>{plans.map((plan) => (<li key={plan.id}>{plan.name}: ${plan.price}/mo</li>))}</ul>);}
And the opposite — forcing a page to render dynamically on every request:
// app/feed/page.tsx// Force SSR: every request gets fresh data, no caching.export const dynamic = "force-dynamic";export default async function FeedPage() {const feed = await fetch("<https://api.example.com/feed>").then((r) =>r.json(),);return (<ul>{feed.map((item) => (<li key={item.id}>{item.title}</li>))}</ul>);}
Here's the cheat sheet:
| Export | Value | Effect |
|---|---|---|
| dynamic | "auto" (default) | Next.js infers from your code |
| dynamic | "force-static" | Force SSG — errors if dynamic APIs are used |
| dynamic | "force-dynamic" | Force SSR — equivalent to cache: "no-store" on every fetch |
| revalidate | false (default) | Cache forever (SSG) |
| revalidate | 0 | No cache (SSR) |
| revalidate | 60 (number) | Revalidate after N seconds (ISR) |
The key insight: dynamic controls whether the page renders statically or dynamically. revalidate controls how long the cache lives. Together, they give you fine-grained control at the route level — and they override whatever Next.js would have inferred from your fetch calls.
When to use them: When the automatic inference gets it wrong, or when you want to make the rendering strategy visible in the code instead of implicit in fetch options. I prefer being explicit — a dynamic = "force-static" at the top of a file is documentation that any team member can read.
The Decision Framework
Here's the mental model in one table. Ask yourself one question about each route's data, and the answer gives you the pattern:
| Data freshness need | Pattern | Config lever | Cache behavior |
|---|---|---|---|
| Never changes (or only on deploy) | SSG | dynamic = "force-static" | Built once, cached forever |
| Changes, but minutes/hours old is fine | ISR | revalidate = 60 | Cached, revalidated on interval |
| Must be fresh every request | SSR | dynamic = "force-dynamic" | No cache, rendered per request |
| User-specific, no SEO need | CSR | "use client" | Rendered in the browser |
| Static shell + dynamic holes | PPR | <Suspense> boundaries | Shell cached, dynamic streamed |
You can — and should — mix these within a single app. Your marketing pages are SSG. Your blog index is ISR. Your dashboard is SSR. Your settings panel is CSR. Your product pages are PPR.
The biggest mistake I see is teams picking one strategy for their entire app. Next.js was designed to let you choose per route. Use that.
The Takeaway
Rendering strategy is a data freshness decision. Stop defaulting to SSR because it feels safe, and stop avoiding SSG because it feels limited. Ask "how fresh does this data need to be?" for each route, match it to the table above, and move on.
The best Next.js apps aren't the ones with the cleverest rendering setup. They're the ones where every route uses the simplest strategy that satisfies its data requirements.
What rendering pattern do you default to, and has it burned you yet? I'd love to hear your war stories - drop a comment below.