
Pick SSG for content that rarely changes (marketing pages, docs), SSR for per-request data (dashboards, anything reading cookies), and ISR (revalidate=N) for content that updates on a known cadence (blog posts, product catalogs). In the Next.js 15 App Router, this is no longer a decision per page; it is a decision per fetch, controlled by force-static, force-dynamic, or revalidate, with PPR letting you mix both inside one route.
That is the answer. The rest of this post is the decision tree, the working code, and the cache-invalidation patterns nobody publishes.
The old Pages Router gave you three functions (getStaticProps, getServerSideProps, getStaticProps + revalidate) and a clean mental model: one page, one strategy. The App Router blew that up.
Two things shifted that most articles still get wrong:
fetch() is no longer cached by default. Next.js 14 cached every fetch. Next.js 15 reversed it. If you migrated a project and your origin traffic 10x'd, this is why.So the correct framing in 2026 is: every fetch and every Server Component has a caching posture. Your job is to set it deliberately, not let the framework guess.
Start with the content. Ask three questions in order.
Question 1: Does this response depend on the request (cookies, headers, search params, user session)?
If yes, SSR. You have no choice. Reading cookies(), headers(), or searchParams automatically opts the route into dynamic rendering. Don't fight it.
Question 2: Does the data change more often than you can afford to redeploy? If no (think: a pricing page, a docs site, a marketing landing), use SSG. Pure static, served from the edge, near-zero origin cost.
Question 3: Does the data change on a predictable cadence, or via a known event (CMS publish, inventory sync, daily cron)?
If yes, ISR. You get static-page performance with a freshness window you control. Pair it with revalidateTag on a webhook and you get the best of both.
That is the whole tree. Everything else (PPR, streaming, client components) is an optimization on top of one of those three baselines.
| Strategy | When to use | App Router primitive | Origin cost |
|---|---|---|---|
| SSG (force-static) | Marketing, docs, legal, blog index | export const dynamic = 'force-static' or no dynamic APIs | One render per deploy |
| ISR (revalidate=N) | Blog posts, product catalogs, news, leaderboards | export const revalidate = 3600 or fetch(url, { next: { revalidate } }) | One render per N seconds per page |
| SSR (force-dynamic) | Dashboards, account pages, anything reading cookies | export const dynamic = 'force-dynamic' or call cookies()/headers() | One render per request |
| PPR | SEO + dynamic mix on the same route | experimental_ppr = true + <Suspense> boundaries | Static shell free, dynamic islands per request |
These are the only knobs you need to know. Everything else in the docs is a derivative.
A marketing page that pulls copy from a CMS at build time:
// app/(marketing)/pricing/page.tsx
export const dynamic = 'force-static';
export default async function Pricing() {
const tiers = await fetch('https://cms.example.com/pricing', {
cache: 'force-cache',
}).then(r => r.json());
return <PricingTable tiers={tiers} />;
}
The page renders once at build time. Every visitor gets the same prerendered HTML, served from the CDN. To update, you redeploy. If you can live with that latency (and for a pricing page, you can), this is the cheapest, fastest option you have.
A blog post that the marketing team edits in a headless CMS:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // 1 hour
export default async function Post({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 },
}).then(r => r.json());
return <Article post={post} />;
}
The first request after the revalidation window triggers a background regeneration. The current visitor still sees the stale HTML (instantly), and the next visitor sees the fresh version. This is the stale-while-revalidate pattern, baked into the framework.
The trick: set revalidate at both the route segment level and inside fetch. The framework uses the lower of the two. Mixing them is a common source of "why is my page stale" tickets.
An account dashboard that reads the session cookie:
// app/(app)/dashboard/page.tsx
import { cookies } from 'next/headers';
export const dynamic = 'force-dynamic';
export default async function Dashboard() {
const session = (await cookies()).get('session')?.value;
const data = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${session}` },
cache: 'no-store',
}).then(r => r.json());
return <DashboardView data={data} />;
}
You don't actually need force-dynamic here; calling cookies() already opts the route into dynamic rendering. But adding the export makes intent explicit, which matters when the next engineer reads the file.
The most underused primitive. A product detail page that needs to be SEO-indexable (static OG tags, static description) but also shows live inventory and a personalized "you viewed this" widget:
// next.config.js
module.exports = { experimental: { ppr: 'incremental' } };
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
export const experimental_ppr = true;
export const revalidate = 600;
export default async function Product({ params }: { params: { slug: string } }) {
const product = await fetch(`https://api.example.com/products/${params.slug}`).then(r => r.json());
return (
<>
<ProductHero product={product} /> {/* static, prerendered */}
<Suspense fallback={<InventorySkeleton />}>
<LiveInventory sku={product.sku} /> {/* dynamic, streamed */}
</Suspense>
<Suspense fallback={null}>
<RecentlyViewed /> {/* dynamic, reads cookies */}
</Suspense>
</>
);
}
The static shell ships from the CDN in the first packet. Google indexes it. The dynamic islands stream in over the same response. You get SSG SEO and SSR personalization on the same URL.
PPR is the right answer for most ecommerce, marketplaces, and content sites with a logged-in state. It is still flagged experimental in Next.js 15 but stable enough for production at companies running it (Vercel, Notion, several of our customers' apps). Plan to use it.
ISR's killer feature is on-demand invalidation. A revalidate=3600 page does not have to wait an hour if the underlying data changes. You can poke it.
revalidatePath from a Server ActionWhen a logged-in admin publishes a post, refresh the public page immediately:
// app/admin/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function publishPost(slug: string) {
await db.post.update({ where: { slug }, data: { published: true } });
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog'); // also bust the index
}
revalidateTag from a CMS webhookFor a Sanity, Contentful, or Strapi project, tag every fetch with the content type, then bust by tag from a webhook handler:
// in your page
const post = await fetch(url, { next: { tags: [`post:${slug}`, 'posts'] } });
// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { type, slug } = await req.json();
if (!verifySignature(req)) return new Response('forbidden', { status: 403 });
revalidateTag(`${type}:${slug}`);
revalidateTag(`${type}s`);
return Response.json({ ok: true });
}
This is what makes ISR feel real-time without paying the SSR cost. The first reader after the webhook fires gets the new content. Origin renders go from "every request" to "every CMS edit," which on a typical SaaS marketing site is a 1000:1 reduction.
Belt and suspenders. Use revalidate=3600 so even if the webhook fails, the page is at most an hour stale. This is the production default and it pairs nicely with the structure of a Next.js project for scale when you have many content types feeding many routes.
A few that we see on every Next.js audit.
Cookies and headers force-dynamic transitively. Calling cookies() deep inside a Server Component (say, inside a shared <Header /> used in your layout) makes every route under that layout dynamic. Your "static" marketing pages quietly become SSR. Symptom: surprise origin bill.
Fetch defaults changed in v15. If you ported a v13 or v14 project, your fetches are no longer cached by default. Add cache: 'force-cache' (or next: { revalidate }) explicitly. Symptom: 10x origin requests after upgrade.
Layouts cache, but Server Actions don't bust them. revalidatePath('/blog/post') does not refresh the layout segment by default. If you mutate something the layout shows (a cart count, say), pass 'layout' as the second arg: revalidatePath('/', 'layout').
Build-time data fetches in SSG can fail silently. If your CMS returns a 500 during next build, you get a placeholder page deployed. Always wrap build-time fetches in retry logic, and gate the build on a smoke test. The same hygiene applies when you deploy a Next.js app to Vercel.
generateStaticParams returning [] is not "no params"; it's "all params dynamic." Surprises people coming from Pages Router. If you want zero prerendering, return [] and set dynamicParams = true. If you want a closed set of static pages, return the array of params and set dynamicParams = false.
If you are pre-revenue with two founders and a landing page, this whole post is overkill. Use the App Router defaults. Run next build. Ship.
The decision matters when you have:
Below that scale, pick "default" (which is roughly SSG with no caching of fetches) and revisit when something breaks. Best practices have ROI curves; respect them.
This is exactly the kind of decision that separates a "did the tutorial" Next.js dev from someone who has shipped it three times. The right call depends on your traffic shape, your CMS, your SEO posture, and how much origin cost you can absorb.
Every engineer on Cadence is AI-native by default (Cursor and Claude Code daily, vetted before they unlock bookings), which matters here because most ISR/PPR debugging is reading cache logs, diffing build outputs, and reasoning about request timing. AI-fluent engineers move through that loop faster. A senior tier engineer ($1,500/week) will typically audit your rendering choices, instrument cache hit rates, and ship a PPR migration in under a week. If you want a second opinion before refactoring, audit your stack with Ship or Skip and you'll get an honest grade on whether your current rendering setup is actually the bottleneck.
For deeper App Router work, the Pages to App Router migration guide covers the route-by-route transition pattern, and our notes on optimizing React performance in 2026 cover the React Compiler interplay with Server Components.
If your team is hitting Next.js 15 caching issues this week, book a senior engineer for a 48-hour free trial on Cadence. Ship one PR, see if the work moves. No notice period if it doesn't.
Yes, but the API moved. Instead of getStaticProps with a revalidate field, you use export const revalidate = N at the route level, or fetch(url, { next: { revalidate: N } }) inline. The behavior is the same: stale-while-revalidate caching with a configurable freshness window.
For most apps with a mix of static SEO surfaces and personalized content, yes. PPR is marked experimental in Next.js 15 but is stable enough that Vercel runs it on their own properties. Start with one route (a product page or blog post), measure the result, and expand.
getServerSideProps?Nothing, and everything. Any async Server Component that reads cookies(), headers(), or searchParams is effectively SSR. You can also force it with export const dynamic = 'force-dynamic'. There's no single function to call; rendering posture is inferred from the APIs you use.
Run next build and look at the route summary. Routes prefixed with a circle (○) are static, lambda (λ) are dynamic, and the new partial-prerender icon shows PPR routes. This summary is the source of truth; if a page you expected to be static shows up as dynamic, something in the tree is calling a dynamic API.
It works on any Node host that supports the Next.js custom server (or the standalone output). On non-Vercel hosts you need persistent storage for the cache (typically a shared filesystem or an external cache like Redis) and your CDN needs to honor the cache-control headers Next.js emits. Self-hosting ISR on Kubernetes is doable but requires care; if you want one provider that handles it for you, Vercel is still the path of least resistance.