Server-Side Rendering (SSR) for Web Application
Server-Side Rendering means server generates full HTML before sending to browser. User sees content immediately without waiting for JS bundle load and client render execution. For SEO, for first load on slow connections, for accessibility — SSR provides measurable benefits over pure client application.
But SSR is not a button. It's an architectural decision with tradeoffs that need to be understood and properly balanced.
SSR models and their application
Full SSR (traditional): each request → server render → full HTML response. No state on client before hydration.
SSR with hydration: server renders HTML, client loads the same JS code and "revives" static HTML — attaches events, restores state.
Streaming SSR: HTML sent to browser as page parts become ready, not waiting for full render. First bytes reach browser faster.
SSR with caching: render result cached for specified time — server doesn't render same thing on each request.
Implementation on Next.js (React)
Next.js is most mature SSR framework for React. App Router (Next.js 13+) offers React Server Components as default model:
// app/products/[id]/page.tsx — server component
import { notFound } from 'next/navigation';
interface Props {
params: { id: string };
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 }, // ISR: cache 60 seconds
});
if (!res.ok) return null;
return res.json() as Promise<Product>;
}
export default async function ProductPage({ params }: Props) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductActions productId={product.id} /> {/* Client component */}
</article>
);
}
// Metadata for SEO — also server
export async function generateMetadata({ params }: Props) {
const product = await getProduct(params.id);
return {
title: product?.name ?? 'Product not found',
description: product?.description,
openGraph: { images: [product?.image] },
};
}
// app/products/[id]/product-actions.tsx — client component
'use client';
import { useState } from 'react';
export function ProductActions({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
async function addToCart() {
setLoading(true);
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
setLoading(false);
}
return (
<button onClick={addToCart} disabled={loading}>
{loading ? 'Adding...' : 'Add to cart'}
</button>
);
}
Implementation on Nuxt 3 (Vue)
<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: product, error } = await useFetch<Product>(
`/api/products/${route.params.id}`,
{ key: `product-${route.params.id}` }
);
if (error.value || !product.value) {
throw createError({ statusCode: 404 });
}
useSeoMeta({
title: product.value.name,
description: product.value.description,
ogImage: product.value.image,
});
</script>
<template>
<article>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<ClientOnly>
<ProductActions :product-id="product.id" />
</ClientOnly>
</article>
</template>
Hydration: pitfalls
Hydration mismatch — most common SSR issue. If server and client HTML differ, React/Vue throw warning or completely re-render component:
// Problem: new Date() gives different result on server and client
function LastUpdated() {
return <span>{new Date().toLocaleString()}</span>; // Mismatch!
}
// Solution: suppressHydrationWarning for dynamic values
function LastUpdated({ timestamp }: { timestamp: string }) {
return (
<time suppressHydrationWarning dateTime={timestamp}>
{new Date(timestamp).toLocaleString()}
</time>
);
}
For browser-dependent code (localStorage, window.innerWidth) — deferred render:
'use client';
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
if (!theme) return null; // Don't render until mounted
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
Server-side caching
// lib/cache.ts — Redis cache for expensive requests
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttl = 300 // seconds
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Usage in server component
const categories = await cachedFetch(
'categories:all',
() => db.category.findMany({ orderBy: { name: 'asc' } }),
3600
);
Metrics and monitoring
SSR introduces server latency into render chain. Important to track:
| Metric | Target value |
|---|---|
| TTFB (Time to First Byte) | < 200ms |
| LCP (Largest Contentful Paint) | < 2.5s |
| FCP (First Contentful Paint) | < 1.8s |
| Server render p95 | < 500ms |
Tools: Vercel Analytics, Sentry Performance, OpenTelemetry + Jaeger for server request tracing.
Deployment and infrastructure
- Vercel / Netlify — automatic Next.js/Nuxt deploy, Edge Runtime for low latency
- Node.js on VPS — via PM2 or Docker, needs reverse proxy (Nginx)
- Cloudflare Workers — only edge-compatible code (no Node.js API)
- AWS App Runner / ECS — for enterprise with isolation requirements
Implementation timeline
- Weeks 1–2: stack choice (Next.js/Nuxt/SvelteKit), routing setup, server components for static pages
- Week 3: dynamic routes, server data handlers, SEO metadata
- Week 4: client components for interactive parts, solve hydration mismatches
- Week 5: caching (Redis/in-memory), TTFB optimization
- Week 6: load testing, monitoring setup, deployment







