MW

How-To · Frameworks

Next.js Server-Side Rendering (SSR, SSG, ISR) — Complete Guide (2026)

Next.js gives you three rendering modes: SSR (every request), SSG (build time), and ISR (build + revalidate). Here's how to pick the right one and the exact App Router syntax for each.

Jan 24, 2025 8 min read Any platform intermediate
Advertisement

Next.js App Router gives you three rendering strategies that you pick per route via exports at the top of page.tsx. Get this right and your site is fast + always-fresh; get it wrong and you’ll either burn server CPU or serve stale data.

my-next-app/
app/
blog/
page.tsx SSG (default)
[slug]/
page.tsx ISR with revalidate
dashboard/
page.tsx SSR (force-dynamic)
about/
page.tsx SSG
next.config.js

Rendering overview

ModeWhen it rendersBest for
SSG (default)Build timeMarketing pages, docs, blog posts that change infrequently
SSREvery requestPersonalized dashboards, real-time data, user-specific content
ISRBuild, then revalidated at intervalsNews feeds, product listings, anything that changes hourly-ish
CSR (client)BrowserHighly interactive widgets, after-auth views

App Router defaults to SSG. You only opt out when you actually need SSR or ISR.

SSG — the default

Any page.tsx with no special exports is statically rendered at build time:

// app/about/page.tsx — renders once at `next build`
export default async function AboutPage() {
  return <main>About me — never changes between deploys.</main>;
}

For dynamic routes with known slugs, implement generateStaticParams:

// app/blog/[slug]/page.tsx
import { getAllPosts, getPostBySlug } from '@/lib/posts';

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}
Terminal — what SSG looks like in `next build`
$ npm run build
Route (app) Size First Load JS ┌ ○ / 1.2 kB 87 kB ├ ● /blog/[slug] 1.8 kB 90 kB ├ ├ /blog/hello-world ├ ├ /blog/quick-fix-2026 ├ └ /blog/empire-shells ├ ○ /about 1.1 kB 87 kB └ λ /dashboard 4.2 kB 93 kB ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses getStaticProps) λ (Dynamic) server-rendered on demand

SSR — force-dynamic

Opt into per-request rendering with a single export:

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';

export default async function DashboardPage() {
  const session = await getSession();
  const data = await getUserData(session.userId);
  return <Dashboard data={data} />;
}

Now every request to /dashboard runs the function and returns fresh HTML.

Alternative SSR signals

Next.js also infers force-dynamic when you use these inside a page:

  • cookies() from next/headers
  • headers() from next/headers
  • A fetch() call with cache: 'no-store'
  • A search-param-dependent calculation

So you often don’t need the explicit export — just use a dynamic API and Next.js handles the rest.

ISR — revalidate

Combine SSG-style speed with periodic freshness:

// app/blog/[slug]/page.tsx
export const revalidate = 60;  // seconds

export async function generateStaticParams() {
  // ...same as SSG
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}

What happens:

  1. First request after build → serves the cached static HTML
  2. Background regeneration kicks off if cache is older than 60s
  3. Next request serves fresh HTML once regenerated
  4. Failed regeneration silently keeps serving stale (graceful degradation)

On-demand revalidation

For event-driven freshness (e.g., new blog post published), trigger revalidation explicitly:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { secret, path } = await request.json();
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }
  revalidatePath(path);
  return NextResponse.json({ revalidated: true, path });
}

Now your CMS can POST to /api/revalidate whenever content changes and the relevant page rebuilds within seconds.

Data fetching in App Router

The classic getServerSideProps / getStaticProps are gone in App Router. Just use async components with await fetch():

// app/posts/page.tsx
export default async function PostsPage() {
  // SSG by default — fetched at build time
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());

  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

Control caching via the fetch options:

// SSR (no cache)
const data = await fetch('...', { cache: 'no-store' });

// ISR (revalidate every 60s)
const data = await fetch('...', { next: { revalidate: 60 } });

// SSG (default — cached forever until next build)
const data = await fetch('...');

// Tagged revalidation (invalidate by tag)
const data = await fetch('...', { next: { tags: ['posts'] } });
// later: revalidateTag('posts')

When to use what

Static content (about, docs, marketing)             → SSG (default, no exports needed)
Logged-in dashboards, personalized views            → SSR (`force-dynamic` or dynamic API)
Blog posts, product pages, news feeds               → ISR (`revalidate: 60` to `3600`)
Real-time data (stock prices, chat)                 → CSR (client components + SWR/React Query)
Mixed content (mostly static, one dynamic widget)   → SSG/ISR page + Client Component island

Best practices

  1. Default to SSG — opt out only when needed. Saved compute matters at scale.
  2. Co-locate revalidate with the data source — set revalidate in the fetch call rather than as a page export when only some data is fresh-sensitive.
  3. Don’t mix force-dynamic with generateStaticParams — Next.js will warn; pick one.
  4. Use revalidateTag for cross-route invalidation — when one DB write should refresh 5 routes, tag the fetches and invalidate once.
  5. Edge runtime for pure-compute pagesexport const runtime = 'edge' cuts cold start to under 50ms for routes that don’t need Node APIs.

Common pitfalls

SymptomCauseFix
Build fails with “must implement generateStaticParams”Dynamic route [slug] without generateStaticParamsImplement it, or set dynamic = 'force-dynamic'
Stale data after deploySSG cached at build timeSwitch to ISR with revalidate, or use on-demand revalidatePath()
Server costs spikingAccidental force-dynamic on a popular pageAudit page-level exports + fetch cache options
cookies() errors at buildUsing cookies inside an SSG pageAdd export const dynamic = 'force-dynamic'
ISR not refreshingVercel deploys reset the ISR cache; or revalidate is in the futureCheck next.config.js, verify timestamp

Conclusion

Three rendering modes, one decision per route:

  • SSG → fast, free, static-fresh data only
  • SSR → expensive, fresh-every-time, user-specific data
  • ISR → fast + periodically fresh, the sweet spot for most content sites

App Router makes the choice explicit and per-route. Use SSG by default; opt up to ISR for hourly-freshness; reach for SSR only when neither works.

Next.js SSR Server-Side Rendering React Performance
Advertisement

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.