Server-Side Rendering
Improve performance and SEO with server-side rendering using Next.js, Nuxt, Astro, and other frameworks with hydration strategies.
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:
- The browser requests the page.
- The server runs your components, fetches data, and serializes the result into HTML.
- The browser paints immediately — this is the fast LCP SSR is for.
- The JS bundle downloads and hydration walks the DOM, attaching event listeners to the markup that already exists.
- From that point on, client-side navigation takes over like a normal SPA.
SSR vs. SSG vs. CSR vs. ISR:
| Strategy | Renders | Use it for | Main risk |
|---|---|---|---|
| SSR | Every request | Personalized or live data | Server load, TTFB on cold cache |
| SSG | Once at build | Static content | Stale content between deploys |
| CSR | In the browser | Auth’d apps, heavy interactivity | Slow first paint, weak SEO |
| ISR | Build + background revalidate | Catalogs, news | Stale window while revalidating |
Variants
| Framework | Model | Strength |
|---|---|---|
| Next.js | SSR + SSG + ISR + streaming | Mature ecosystem, RSC, edge runtime |
| Nuxt | SSR + SSG + hybrid routes | Vue, file-based routing, Nitro engine |
| Astro | SSG-first + islands | Zero JS by default, framework-agnostic islands |
| SvelteKit | SSR + CSR hybrid | Small bundles, edge-ready adapters |
| Remix | SSR + progressive enhancement | Works 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=600lets 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.allindependent calls — sequential awaits create waterfalls that push TTFB into seconds.
Common Mistakes
- Hydrating the whole app: shipping JS for static content defeats the point — audit which components actually need interactivity.
- Non-deterministic render output:
Date.now(),Math.random(), orwindowduring render produces different HTML on server and client → hydration mismatches. - 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.
- Caching personalized pages: SSR +
publiccaching on a per-user page leaks one user’s data to the next — mark thoseprivateorno-store. - 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/localStorageaccess, 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
awaitchain 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
fetchcache) is treating a personalized response as public — inspect the response’sCache-Controland 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); checkrobotsmeta and confirm you’re not rendering an empty shell that waits for client data.
Further Reading
- Rendering on the Web — web.dev — Google’s breakdown of SSR, SSG, CSR trade-offs.
- Next.js: Server and Client Components — how the App Router splits rendering.
- Astro: Islands Architecture — partial hydration explained.
- React docs: hydrateRoot — what hydration actually does.
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.
Related Resources
Complete Guide to React Server Components: RSC Architecture
Master React Server Components: RSC architecture, data loading, streaming, server actions, and client component boundaries in Next.js App Router.
RecipeWeb Performance Optimization
Improve Core Web Vitals, reduce bundle sizes, and optimize frontend performance with lazy loading, code splitting, and modern build tools.
RecipeHTTP Cache-Control Headers for APIs and Static Assets
Set Cache-Control, ETag, and Last-Modified headers to control browser and CDN caching for API responses and static assets
RecipeSPA Performance: Code Splitting and Lazy Loading
Improve single-page application load times by splitting bundles at route and component level, implementing lazy loading with React.lazy and live imports
RecipeJavaScript Service Worker Offline Caching for PWA
Cache assets for offline PWA support with Service Workers and Cache API