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 user

When 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 rebuild

How 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 Suspense

Streaming 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 ISR

Checking 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.

Leave a Comment