StackPractices
intermediate By Mathias Paulenko

Server-Side Rendering

Improve performance and SEO with server-side rendering using Next.js, Nuxt, Astro, and other frameworks with hydration strategies.

Topics: frontend

Overview

Server-side rendering (SSR) generates the HTML for each request on the server and sends a fully rendered page to the browser. Users see real content fast, crawlers index the actual markup (critical for SEO), and social previews pull correct Open Graph tags. Modern frameworks — Next.js, Nuxt, Astro, SvelteKit — then ship JavaScript that “hydrates” that static HTML into an interactive app.

SSR isn’t free: you pay for a render on every request, and caching becomes harder than with a static export. This recipe covers when it’s worth paying that cost, how to implement it across the major frameworks, and how to avoid the production issues that hit almost every SSR deployment.

When to Use

  • Content-heavy pages that depend on search indexing (docs, product catalogs, articles).
  • Pages shared on social media where accurate OG previews matter.
  • Public landing pages where a slow first paint hurts Core Web Vitals and conversion.
  • Personalized pages where SSG can’t produce the right content at build time.

When NOT to Use

  • Internal dashboards behind authentication — crawlers never see them, so CSR is cheaper.
  • Pages that depend on live client-side state (offline-first apps, canvases, editors).
  • Very high-traffic, mostly-static pages — SSG behind a CDN handles them at a fraction of the server cost.

Solution

Next.js App Router with Streaming SSR

// app/products/page.tsx — renders on the server for every request
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }, // ISR: serve cached for 60s, revalidate in background
  });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <h1>Products</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            {p.name} — ${p.price}
          </li>
        ))}
      </ul>
    </main>
  );
}

Async server components fetch data on the server and never ship that code to the browser — secrets stay safe and the client bundle stays small. For pure SSR without caching, pass cache: 'no-store' to fetch instead.

Astro Islands Architecture

---
// Runs on the server (or at build time for static pages)
const response = await fetch('https://api.example.com/stats');
const stats = await response.json();
---

<html>
  <body>
    <h1>Dashboard</h1>
    <!-- Plain HTML, zero JS shipped -->
    <p>Total Users: {stats.users}</p>

    <!-- Only this island hydrates on the client -->
    <LiveChart client:load data={stats.chart} />
  </body>
</html>

Astro renders everything to HTML by default and only hydrates components you mark with a client:* directive — partial hydration instead of hydrating the whole tree.

Nuxt 3 SSR with useFetch

<script setup>
const { data: posts } = await useFetch('/api/posts', {
  server: true, // fetch on the server during SSR
  default: () => [],
});
</script>

<template>
  <div>
    <h1>Blog</h1>
    <article v-for="post in posts" :key="post.id">
      <h2>{{ post.title }}</h2>
      <p>{{ post.excerpt }}</p>
    </article>
  </div>
</template>

Minimal SSR by Hand (Express + React)

Useful to understand what the frameworks abstract. Don’t do this in production — it skips streaming, code splitting, and asset handling.

// server.js — renders the React tree to HTML per request
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { App } from './App.js';

const app = express();

app.get('*', (req, res) => {
  const html = renderToString(<App url={req.url} />);
  res.send(`<!doctype html>
    <html><body>
      <div id="root">${html}</div>
      <script src="/client.js"></script>  <!-- hydrates the same tree -->
    </body></html>`);
});

app.listen(3000);

How It Works

The request lifecycle:

flowchart diagram: Browser request
  1. The browser requests the page.
  2. The server runs your components, fetches data, and serializes the result into HTML.
  3. The browser paints immediately — this is the fast LCP SSR is for.
  4. The JS bundle downloads and hydration walks the DOM, attaching event listeners to the markup that already exists.
  5. From that point on, client-side navigation takes over like a normal SPA.

SSR vs. SSG vs. CSR vs. ISR:

StrategyRendersUse it forMain risk
SSREvery requestPersonalized or live dataServer load, TTFB on cold cache
SSGOnce at buildStatic contentStale content between deploys
CSRIn the browserAuth’d apps, heavy interactivitySlow first paint, weak SEO
ISRBuild + background revalidateCatalogs, newsStale window while revalidating

Variants

FrameworkModelStrength
Next.jsSSR + SSG + ISR + streamingMature ecosystem, RSC, edge runtime
NuxtSSR + SSG + hybrid routesVue, file-based routing, Nitro engine
AstroSSG-first + islandsZero JS by default, framework-agnostic islands
SvelteKitSSR + CSR hybridSmall bundles, edge-ready adapters
RemixSSR + progressive enhancementWorks without JS; nested data loading

Best Practices

  • Stream slow boundaries: wrap slow components in <Suspense> so the first HTML chunk arrives fast and the rest streams in.
  • Hydrate as little as possible: use islands (client:visible, dynamic imports) instead of hydrating the entire tree.
  • Serialize the minimum state: pass the client only what it needs — never a raw database row set.
  • Cache SSR output at the edge: Cache-Control: public, s-maxage=300, stale-while-revalidate=600 lets a CDN absorb most requests — see HTTP Cache-Control headers for the full reference.
  • Keep secrets server-side: server components and loaders can read env vars; anything imported into a client component ships to the browser.
  • Parallelize server fetches: Promise.all independent calls — sequential awaits create waterfalls that push TTFB into seconds.

Common Mistakes

  1. Hydrating the whole app: shipping JS for static content defeats the point — audit which components actually need interactivity.
  2. Non-deterministic render output: Date.now(), Math.random(), or window during render produces different HTML on server and client → hydration mismatches.
  3. Blocking the whole page on one slow query: a single 5-second API call delays every byte; isolate it behind a boundary or stream it.
  4. Caching personalized pages: SSR + public caching on a per-user page leaks one user’s data to the next — mark those private or no-store.
  5. No graceful fallback: when SSR crashes mid-request, return a degraded static shell instead of a raw 500.

Troubleshooting

  • Hydration mismatch warnings: diff the server HTML against the first client render — the usual culprits are timestamps, randomness, window/localStorage access, or browser-only extensions injecting markup before hydration.
  • Page renders, then flashes different content: same root cause as above — React threw away the server DOM and re-rendered; fix the non-determinism, don’t suppress the warning.
  • High TTFB on a route that used to be fast: look for a new sequential await chain or an uncached API call added to the render path; check server logs for waterfall fetches.
  • Users see each other’s data: a shared cache (CDN or fetch cache) is treating a personalized response as public — inspect the response’s Cache-Control and the cache key (Vary: Cookie/Authorization).
  • Memory grows until the server dies: module-level caches, event listeners, or intervals registered during render live forever — scope per-request work inside the request lifecycle.
  • Crawlers still don’t index the page: verify the SSR output actually contains the content with curl -L (no JS); check robots meta and confirm you’re not rendering an empty shell that waits for client data.

Further Reading

The runnable versions of these examples live in the companion repository: stack-practices-resources/resources/recipes/frontend/server-side-rendering.

Frequently Asked Questions

What's the difference between SSR and hydration?

SSR produces the HTML on the server; hydration is the client-side step where JavaScript attaches event handlers to that HTML. SSR gives you fast first paint; hydration gives you interactivity. You need both for the full model to work.

Does SSR hurt performance?

It improves first paint but adds server cost per request and can make TTFB slower than a static file. Mitigate with streaming, edge caching, and ISR. For content that doesn't change per user, SSG is almost always faster and cheaper.

Should I use SSR or SSG for my blog?

SSG. Blog content changes at deploy time, not per request, so building once and serving from a CDN wins on every metric. Reach for SSR only when the output depends on the request — session, geolocation, live inventory.

How do I fix hydration mismatch errors?

Remove non-deterministic code from the render path: move Date.now(), Math.random(), and browser APIs behind useEffect/onMount or a client:* directive, so server and client produce identical initial HTML. In dev, React highlights the differing subtree.

Can I mix SSR and SSG in the same app?

Yes — that's the normal setup in production. Static marketing and docs pages go SSG; authenticated or personalized routes go SSR. Next.js, Nuxt, and Astro all let you choose per route.

Does SSR improve SEO?

For pages Google crawls, rendering server-side removes the dependency on Google's second rendering wave, which runs JS later and less reliably. SSR'd content is indexed faster and more consistently — it won't save thin content, but it removes a technical risk.

What is streaming SSR?

Instead of waiting for all data before sending anything, the server flushes HTML in chunks: shell first, then each resolved section inside <Suspense> boundaries. Users see the page skeleton in milliseconds even when a slow component takes seconds.

Can SSR expose secrets to the client?

Only if you put them there. Env vars read inside a server component or loader stay on the server; anything referenced by a client component is bundled into the shipped JS. Audit client components for accidental imports of server-only modules.