Next.js Server-Side Rendering (SSR)
Server-Side Rendering means Next.js builds the HTML for a page on the server at the moment a user requests it — not during the build, and not in the browser. Every single request triggers a fresh HTML generation on the server. The user always receives the most current data.
The Custom Order Analogy
Static Site Generation is like a bakery that bakes all its bread in the morning and hands out pre-made loaves all day. Server-Side Rendering is like a made-to-order sandwich shop — when you walk in and place your order, they make your sandwich fresh right then. It takes a bit longer, but you get exactly what is available right now, with today's ingredients.
Static Generation (SSG):
[Build time] → Fetch data → Build HTML → Save file
[Request] → Send saved file instantly
Server-Side Rendering (SSR):
[Build time] → Nothing
[Request] → Fetch fresh data → Build HTML → Send to userWhen to Use SSR
SSR is the right choice when the page content changes frequently or when the page must be personalized per user.
Good uses for SSR:
✓ News and live sports scores
✓ Stock price pages
✓ User dashboard showing their specific data
✓ Search results pages
✓ E-commerce pages with live inventory
✓ Pages requiring cookies or session data
✓ Any page where data changes faster than you can rebuildHow to Enable SSR in App Router
In the App Router, opt into SSR by using cache: "no-store" in your fetch call, or by using dynamic functions like cookies() or headers(). These signals tell Next.js: "Do not cache this — render fresh on every request."
// app/dashboard/page.js
import { cookies } from "next/headers";
async function getUserData(userId) {
const res = await fetch(`https://api.example.com/users/${userId}`, {
cache: "no-store", // ← This enables SSR behavior
});
return res.json();
}
export default async function DashboardPage() {
const cookieStore = cookies(); // Using cookies() also triggers SSR
const userId = cookieStore.get("userId")?.value;
const user = await getUserData(userId);
return (
<main>
<h1>Welcome back, {user.name}</h1>
<p>Your balance: ${user.balance}</p>
</main>
);
}The dynamic Export
You can explicitly force a page to always use SSR with the dynamic export variable.
// app/prices/page.js
export const dynamic = "force-dynamic"; // Always SSR, never cached
async function getLivePrices() {
const res = await fetch("https://api.example.com/prices");
return res.json();
}
export default async function PricesPage() {
const prices = await getLivePrices();
return (
<ul>
{prices.map((item) => (
<li key={item.id}>{item.name}: ${item.price}</li>
))}
</ul>
);
}Diagram: SSR Request Lifecycle
User's browser Next.js Server Database/API
───────────── ────────────── ────────────
│ │ │
│── GET /dashboard ────────────►│ │
│ │── query user data ───────►│
│ │◄── returns user data ─────│
│ │── query recent orders ───►│
│ │◄── returns orders ────────│
│ │ │
│ │ [builds HTML with data] │
│ │ │
│◄── sends complete HTML ───────│ │
│ │ │
[page renders │ │
immediately] │ │Dynamic Functions That Trigger SSR
Using any of these functions automatically makes a page dynamic (SSR). Next.js detects them and skips caching.
import { cookies } from "next/headers";
import { headers } from "next/headers";
// Reading cookies — triggers SSR
const cookieStore = cookies();
const theme = cookieStore.get("theme");
// Reading request headers — triggers SSR
const headersList = headers();
const userAgent = headersList.get("user-agent");
const country = headersList.get("x-country");These functions need the current request to work — they cannot run at build time. So Next.js automatically renders the page fresh for each request when it sees them.
Using Search Parameters
Search params (query strings like ?q=shoes&page=2) also make pages dynamic because their values change per request.
// app/search/page.js
// searchParams comes from the URL: /search?q=shoes&category=clothing
export default async function SearchPage({ searchParams }) {
const query = searchParams.q || "";
const category = searchParams.category || "all";
const results = await fetch(
`https://api.example.com/search?q=${query}&category=${category}`,
{ cache: "no-store" }
).then((r) => r.json());
return (
<div>
<h1>Results for "{query}"</h1>
{results.map((item) => (
<p key={item.id}>{item.name}</p>
))}
</div>
);
}SSR With Request Headers: Geo-Personalization
Some use cases read the request headers to personalize content. A middleware or CDN might set a x-country header, and your SSR page reads it to show region-specific content.
// app/pricing/page.js
import { headers } from "next/headers";
export default async function PricingPage() {
const headersList = headers();
const country = headersList.get("x-country") || "US";
const pricing = await fetch(`https://api.example.com/pricing/${country}`, {
cache: "no-store",
}).then((r) => r.json());
return (
<div>
<h1>Pricing for {country}</h1>
<p>Basic plan: {pricing.currency}{pricing.basicPrice}/month</p>
</div>
);
}Parallel Data Fetching in SSR
Sequential fetches inside an SSR page slow down response time. Fetch data in parallel using Promise.all so all requests fire at the same time.
// ❌ SLOW: Sequential fetches (each waits for the previous)
const user = await fetchUser(id); // waits 200ms
const orders = await fetchOrders(id); // waits 300ms after user
const reviews = await fetchReviews(id);// waits 150ms after orders
// Total wait: 650ms
// ✅ FAST: Parallel fetches (all fire at the same time)
const [user, orders, reviews] = await Promise.all([
fetchUser(id), // \
fetchOrders(id), // all start simultaneously
fetchReviews(id), // /
]);
// Total wait: 300ms (just the slowest one)SSR Error Handling
SSR errors during a request result in an error page. Use error.js in the same folder to catch and display friendly error messages.
// app/dashboard/error.js
"use client";
export default function DashboardError({ error, reset }) {
return (
<div>
<h2>Something went wrong loading your dashboard.</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}SSR Performance Considerations
SSR adds server processing time per request. Keep it fast by:
1. Parallel fetching → use Promise.all
2. Database connection pooling → reuse connections, don't create new ones per request
3. Edge deployment → run SSR at edge nodes close to users
4. Caching parts of the page → use React cache() for repeated data
5. Streaming → send HTML in chunks using SuspenseStreaming SSR: Don't Wait for Everything
Next.js supports streaming, where the server sends HTML in pieces as each part finishes loading. The user sees content progressively instead of waiting for the entire page.
// app/dashboard/page.js
import { Suspense } from "react";
import UserProfile from "@/components/UserProfile";
import RecentOrders from "@/components/RecentOrders";
export default function DashboardPage() {
return (
<main>
{/* This loads instantly — no data needed */}
<h1>Your Dashboard</h1>
{/* Streams in when ready */}
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile />
</Suspense>
{/* Streams in independently */}
<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>
</main>
);
}The heading renders immediately. Profile and orders each stream in as their data arrives, independently. The user sees something useful right away instead of staring at a blank page.
SSR vs SSG: Choosing the Right Strategy
Ask yourself: "How often does this data change?"
Changes every few months? → SSG (rebuild when needed)
Changes daily? → ISR (revalidate on a schedule)
Changes every minute? → SSR (fetch fresh every request)
Different per user? → SSR (must be personalized)
Same for everyone? → SSG or ISRChecking if a Page is Dynamic
Run npm run build. Pages marked with ƒ in the output are dynamically rendered (SSR). Pages marked with ○ or ● are static.
Build output:
○ / → Static
● /blog → SSG (static with data)
ƒ /dashboard → Dynamic (SSR)
ƒ /search → Dynamic (SSR — uses searchParams)Summary
SSR in Next.js App Router activates automatically when you use cache: "no-store", the dynamic = "force-dynamic" export, or dynamic functions like cookies() and headers(). Each user request triggers a fresh render with up-to-date data. Use streaming with Suspense to send HTML progressively and keep the experience fast even when data fetching takes time.
