Skip to main content

How server-side rendering (SSR) works in Next.js

Server-side rendering (SSR) in Next.js generates a complete HTML page on the server for every incoming request, pulling live data before sending the result to the browser.

Theory

TL;DR

  • SSR is like a restaurant cooking your meal to order. SSG pre-makes batches and hands them out.
  • Every request triggers fresh data fetching and a full HTML render on the server.
  • In the App Router, add cache: 'no-store' to a fetch call or call cookies() / headers() to opt into SSR automatically.
  • Use SSR for personalized or frequently changing data. For anything cacheable, SSG or ISR costs less.

Quick example

tsx
// app/dashboard/page.tsx async function Dashboard() { const res = await fetch('https://api.example.com/user-data', { cache: 'no-store' // fresh data per request = SSR behavior }); const data = await res.json(); return <div>Balance: {data.balance}</div>; }

On every request Next.js runs this function on the server, fetches live data, renders <div>Balance: $42.50</div>, and streams the full HTML to the browser. JavaScript arrives later and React hydrates the page for interactivity.

Key difference

SSR differs from SSG in one thing: timing. SSG builds HTML once at deploy time and serves it instantly from a CDN. SSR builds HTML on demand, per request, on the server. That makes the first byte slower but the data always accurate. Compared to client-side rendering (CSR), SSR delivers real content in the initial HTML, so search crawlers see it immediately and users get a meaningful first paint without waiting for JavaScript to run.

When to use

  • Personalized per user - profile pages, dashboards with session data, anything behind a login.
  • Real-time external data - live prices, scores, inventory counts that change by the minute.
  • Auth-dependent content - pages that read cookies or headers to decide what to show.
  • Static or rarely changed content - pick SSG, it is faster and puts zero load on the server.
  • High traffic, data can be a minute old - ISR with revalidate is a better fit than SSR.

Comparison table

FeatureSSRSSGCSR
Data fetchedPer request (server)At build timeAfter page load (client)
Initial HTMLFully renderedPre-built static fileEmpty shell
TTFBHigher (server compute)Fastest (CDN)Fast but no real content
SEOExcellentExcellentPoor without pre-rendering
Server loadPer requestNone after buildLow
Best forUser-specific / live dataMarketing, docsApp-like dashboards post-login

How it works internally

A request hits the Node.js server. Next.js identifies the page as dynamic, either by cache: 'no-store', a call to cookies() or headers(), or export const dynamic = 'force-dynamic'. The async server component runs, fetches data via HTTP or directly from a database. React converts the component tree to an HTML string with ReactDOMServer.renderToString(). That HTML ships to the browser. The browser downloads JS bundles, then ReactDOM.hydrateRoot() attaches event handlers to the existing DOM without re-rendering everything.

Streaming changes this slightly. With React 18 and the App Router, Next.js sends the HTML shell first and streams chunks as each Suspense boundary resolves, so a slow API call does not block the whole page.

Common mistakes

Calling cookies() inside a client component

tsx
// Wrong - throws "Headers cannot be used in Client Components" 'use client'; import { cookies } from 'next/headers'; const session = cookies().get('session'); // error // Fix - read cookies in a server component, pass down as props async function ServerLayout() { const session = cookies().get('session'); return <ClientNav session={session?.value} />; }

next/headers is server-only. Client components run in the browser where request headers do not exist.

Forgetting cache: 'no-store' and getting stale data

tsx
// Wrong - Next.js caches this by default, data gets stale const data = await fetch('/api/live-prices'); // Fix const data = await fetch('/api/live-prices', { cache: 'no-store' });

The App Router caches fetch by default. This surprises most teams moving from the Pages Router, where fetch was uncached. Without cache: 'no-store', your page may return data from the build rather than the current request.

Blocking the whole page on a slow API

tsx
// Wrong - entire page waits for Stripe before any HTML ships export default async function OrdersPage() { const res = await fetch('https://api.stripe.com/v1/orders', { cache: 'no-store' }); // user waits 2+ seconds for the first byte } // Fix - stream the slow part, ship the shell immediately import { Suspense } from 'react'; export default function OrdersPage() { return ( <div> <h1>Your Orders</h1> <Suspense fallback={<p>Loading orders...</p>}> <SlowOrders /> </Suspense> </div> ); }

Without a Suspense boundary, a 2-second Stripe response means a 2-second TTFB and a bad Lighthouse score.

Using SSR on every route by default

Every SSR request runs server code. On high traffic that is expensive and scales poorly. Pages with the same content for every user, blog posts, landing pages, documentation, should be SSG. Keep SSR for pages where the data genuinely differs per user or per request.

Real-world usage

  • Vercel dashboard - billing data pulled from the Stripe API per user session.
  • Netflix - personalized watchlists rendered server-side using per-request user preferences.
  • GitHub - repository pages with live commit counts fetched per request.
  • IT Lead profile - session cookie read via cookies(), DB query for solved problems, rendered fresh on every visit.

Follow-up questions

Q: How does SSR differ from ISR?
A: ISR builds HTML statically but regenerates it in the background on a schedule, for example revalidate: 60 means rebuild at most once per minute. SSR rebuilds HTML from scratch on every single request. ISR is cheaper; SSR is always current.

Q: What happens during a hydration mismatch?
A: React logs a warning in the browser console and may re-render the affected subtree on the client. The usual cause is server and client fetching different data. Fix it by making sure both sides use the same source.

Q: What is the difference between React Server Components and SSR?
A: SSR is a rendering strategy: generate HTML on the server per request. React Server Components (RSC) are a component model: they run only on the server, fetch data inline, and send zero JavaScript to the client. In the Next.js App Router they work together, but they are not the same concept.

Q: What happens at the Node.js level when an SSR request arrives?
A: Next.js runs the async server component tree. Node fetches data via undici for HTTP or a DB driver directly. React calls renderToString() or renderToPipeableStream() for streaming. The HTML flushes through the HTTP response. The browser downloads the JS bundle and hydrateRoot() attaches event listeners.

Q: A slow DB query is adding 1.5 seconds to TTFB. Walk through how you would fix it.
A: First, wrap the slow component in a Suspense boundary so the HTML shell ships immediately. Then check if the query field has an index and whether you are selecting more data than needed. If the result is not user-specific, move the component to ISR with a short revalidation window. Profile with Vercel Speed Insights or a console.time() wrapper to confirm where the time actually goes.

Examples

Basic: opt into SSR with cache: 'no-store'

tsx
// app/prices/page.tsx export default async function PricesPage() { const res = await fetch('https://api.example.com/prices', { cache: 'no-store' // Next.js will not cache this response }); const prices = await res.json(); return ( <ul> {prices.map((item: { id: string; name: string; price: number }) => ( <li key={item.id}>{item.name}: ${item.price}</li> ))} </ul> ); }

No extra configuration needed. The cache: 'no-store' flag is enough for Next.js to skip the static build for this route and fetch live data on every request.

tsx
// app/profile/page.tsx import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; export default async function ProfilePage() { const cookieStore = cookies(); // marks the route as dynamic automatically const sessionId = cookieStore.get('session')?.value; if (!sessionId) redirect('/login'); const user = await db.user.findUnique({ where: { sessionId } }); return ( <div> <h1>Welcome, {user?.name}</h1> <p>Problems solved: {user?.solvedCount}</p> </div> ); }

Calling cookies() from next/headers signals to Next.js that this page depends on the incoming request. No cache: 'no-store' required. The page queries the database fresh on every visit, auth check included.

Advanced: streaming SSR with Suspense for a slow external API

tsx
// app/orders/page.tsx import { Suspense } from 'react'; async function OrderList() { // This fetch blocks only OrderList, not the whole page const res = await fetch('https://api.stripe.com/v1/charges', { cache: 'no-store' }); const { data: charges } = await res.json(); return ( <ul> {charges.map((c: { id: string; amount: number }) => ( <li key={c.id}>${(c.amount / 100).toFixed(2)}</li> ))} </ul> ); } export default function OrdersPage() { return ( <div> <h1>Your Orders</h1> {/* Shell ships immediately. OrderList streams in when ready. */} <Suspense fallback={<p>Loading orders...</p>}> <OrderList /> </Suspense> </div> ); }

The <h1> and the fallback text reach the browser before the Stripe response arrives. Once OrderList resolves, React streams the finished HTML and swaps the fallback out. TTFB stays low even when the upstream API is slow.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?