Expert guidance on server-side rendering for improved performance and SEO. Use for: React SSR with Next.js, Vue SSR with Nuxt, hydration strategies, server state management, streaming SSR, edge rendering, caching strategies, SEO optimization, and building performance-critical web applications.
Covers: Next.js SSR · Nuxt SSR · Hydration · Streaming · Edge Rendering · SEO Optimization · Caching
Server-side rendering generates complete HTML pages on the server in response to client requests. Unlike client-side rendering where the browser builds the page after downloading JavaScript, SSR delivers ready-to-render HTML that improves perceived performance, enables better SEO, and provides optimal experience on slow devices.
The rendering process involves multiple steps: the server receives a request for a page, executes the application code to generate the component tree, fetches any required data from databases or APIs, renders the components to HTML, and sends the complete HTML document to the browser. The browser can display this HTML immediately while downloading and executing JavaScript for hydration.
Key Benefits:
Trade-offs:
| Approach | When HTML Generated | Use Case |
|---|---|---|
| SSR (Server-Side Rendering) | Request time | Dynamic, personalized content |
| SSG (Static Site Generation) | Build time | Documentation, blogs, marketing |
| ISR (Incremental Static Regeneration) | Request + background | Content that updates periodically |
| Edge Rendering | Edge location | Global, low-latency content |
// app/page.tsx - Server Component by default
import { getData } from '@/lib/api';
async function getServerData() {
const res = await fetch('https://api.example.com/data', {
// Cache configuration
cache: 'no-store', // Don't cache, fetch fresh each time
// or: cache: 'force-cache' // Static generation
// or: next: { revalidate: 3600 } // ISR - revalidate every hour
});
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function Page() {
// This component runs on the server
const data = await getData();
return (
<main>
<h1>{data.title}</h1>
<p>{data.description}</p>
<ul>
{data.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</main>
);
}
// app/components/Counter.tsx - Client Component
'use client';
import { useState } from 'react';
export default function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
// app/page.tsx - Using client component in server component
import Counter from './components/Counter';
import { getInitialCount } from '@/lib/api';
export default async function Page() {
const initialCount = await getInitialCount();
return (
<main>
<h1>Server-Rendered Page</h1>
<Counter initialCount={initialCount} />
</main>
);
}
// Parallel data fetching - fetch in parallel for performance
import { getUser, getPosts } from '@/lib/api';
export default async function Page() {
// Start both fetches simultaneously
const userData = getUser();
const postsData = getPosts();
// Wait for both
const [user, posts] = await Promise.all([userData, postsData]);
return (
<div>
<h1>{user.name}</h1>
<PostsList posts={posts} />
</div>
);
}
// Sequential fetching - when data depends on previous
export default async function Page() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const settings = await getUserSettings(user.id);
return <SettingsPage user={user} settings={settings} />;
}
// Streaming with Suspense - show shell while loading
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<PostsSkeleton />}>
<PostsList />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<CommentsList />
</Suspense>
</div>
);
}
async function PostsList() {
const posts = await getPosts();
return <PostCards posts={posts} />;
}
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const limit = searchParams.get('limit') || '10';
const users = await fetchUsers(parseInt(page), parseInt(limit));
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate
if (!body.email || !body.name) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}
// With caching
export async function GET() {
const data = await getExpensiveData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400'
}
});
}
Hydration is the process where client-side JavaScript takes over the server-rendered HTML, attaching event listeners and making the page interactive. Understanding hydration is crucial for optimizing performance, as poorly optimized hydration can lead to slow Time to Interactive (TTI).
Hydration Challenges:
// Strategy 1: Lazy load non-critical components
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(
() => import('./HeavyChart'),
{
loading: () => <ChartSkeleton />,
ssr: false // Don't render on server
}
);
export default function Dashboard({ data }) {
return (
<div>
<SummaryCards data={data} />
{/* Chart loads after initial render */}
<HeavyChart data={data.chartData} />
</div>
);
}
// Strategy 2: use client boundary
// components/InteractiveMap.tsx
'use client';
import { useState, useEffect } from 'react';
export default function InteractiveMap({ markers }) {
const [map, setMap] = useState(null);
useEffect(() => {
// Load map library only on client
initMap().then(setMap);
}, []);
return <div id="map" />;
}
// Only hydrate the interactive part
// components/CommentSection.tsx
'use client';
import { useState } from 'react';
export default function CommentSection({ initialComments }) {
const [comments, setComments] = useState(initialComments);
// Only interactive parts hydrate
return (
<div>
{comments.map(comment => (
<Comment key={comment.id} data={comment} />
))}
<AddCommentForm onSubmit={addComment} />
</div>
);
}
// components/Comment.tsx - Can stay static if no interactivity
export default function Comment({ data }) {
// Static display - no client JS needed
return (
<article>
<strong>{data.author}</strong>
<p>{data.text}</p>
</article>
);
}
// lib/hydration-manager.tsx
'use client';
import { useState, useEffect } from 'react';
export function useHydrated() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
return hydrated;
}
export function ClientOnly({ children, fallback = null }) {
const hydrated = useHydrated();
return hydrated ? children : fallback;
}
// Usage
import { ClientOnly } from '@/lib/hydration-manager';
export default function Page() {
return (
<div>
<ServerRenderedContent />
<ClientOnly fallback={<Skeleton />}>
<ClientSideOnlyComponent />
</ClientOnly>
</div>
);
}
// Enable streaming in Next.js (default in App Router)
// Just use Suspense boundaries
import { Suspense } from 'react';
export default function Page() {
return (
<div className="layout">
<Header /> {/* Immediate render */}
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Independent stream */}
</Suspense>
<Footer /> {/* Immediate render */}
</div>
);
}
// components/Feed.tsx
async function Feed() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</ul>
);
}
// components/ErrorBoundary.tsx
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Error in component:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage with Suspense
export default function Page() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
<Content />
</Suspense>
</ErrorBoundary>
);
}
// app/api/hello/route.ts
export const runtime = 'edge';
export async function GET(request: Request) {
return new Response('Hello from the edge!', {
headers: { 'content-type': 'text/plain' }
});
}
// Edge middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Geo-based routing
const country = request.geo?.country || 'US';
if (country === 'DE') {
return NextResponse.redirect(new URL('/de', request.url));
}
// A/B testing
const bucket = Math.random() < 0.5 ? 'a' : 'b';
const response = NextResponse.next();
response.cookies.set('ab-test', bucket);
return response;
}
export const config = {
matcher: '/:path*',
};
// app/products/[id]/page.tsx
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <ProductDetails product={product} />;
}
// For static content with edge caching
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map((product) => ({
id: product.id,
}));
}
export const revalidate = 3600; // Revalidate at edge every hour
// app/blog/[slug]/page.tsx
// Static generation at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
// ISR - revalidate every hour
export const revalidate = 3600;
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
// Cache tags for on-demand revalidation
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <Product product={product} />;
}
export async function generateStaticParams() {
const products = await getAllProducts();
return products.map((p) => ({ id: p.id }));
}
// Using fetch cache options
const res = await fetch(`https://api.example.com/products/${params.id}`, {
next: {
revalidate: 60, // Cache for 60 seconds
tags: ['products'], // For on-demand invalidation
},
});
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
// Verify secret token
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
}
const body = await request.json();
const { tag, path } = body;
if (tag) {
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
if (path) {
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
return NextResponse.json({ message: 'Missing tag or path' }, { status: 400 });
}
// Webhook handler for CMS updates
// app/api/cms-webhook/route.ts
export async function POST(request: NextRequest) {
const body = await request.json();
if (body.type === 'post_updated') {
revalidateTag('posts');
revalidatePath('/blog');
}
return NextResponse.json({ received: true });
}
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'My Awesome Site',
template: '%s | My Awesome Site',
},
description: 'Description of my awesome website',
openGraph: {
title: 'My Awesome Site',
description: 'Description of my awesome website',
url: 'https://myawesomesite.com',
siteName: 'My Awesome Site',
images: [
{
url: 'https://myawesomesite.com/og-image.jpg',
width: 1200,
height: 630,
},
],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'My Awesome Site',
creator: '@username',
},
robots: {
index: true,
follow: true,
},
};
// Dynamic metadata
export async function generateMetadata({ params }): Promise<Metadata> {
const product = await getProduct(params.id);
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image],
},
};
}
// components/ProductSchema.tsx
export function ProductSchema({ product }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: product.currency,
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
aggregateRating: product.rating ? {
'@type': 'AggregateRating',
ratingValue: product.rating.value,
reviewCount: product.rating.count,
} : undefined,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
// JSON-LD for organization
export function OrganizationSchema() {
const schema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Company Name',
url: 'https://company.com',
logo: 'https://company.com/logo.png',
contactPoint: {
'@type': 'ContactPoint',
telephone: '+1-555-555-5555',
contactType: 'customer service',
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://myawesomesite.com';
// Fetch dynamic content
const posts = await getAllPosts();
const products = await getAllProducts();
const staticPages = [
'',
'/about',
'/contact',
'/pricing',
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: route === '' ? 1 : 0.8,
}));
const blogPosts = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
const productPages = products.map((product) => ({
url: `${baseUrl}/products/${product.id}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'daily' as const,
priority: 0.9,
}));
return [...staticPages, ...blogPosts, ...productPages];
}
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
experimental: {
optimizePackageImports: ['lodash', 'moment', 'antd'],
},
webpack: (config, { isServer }) => {
// Optimize third-party imports
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
}
return config;
},
});
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body>{children}</body>
</html>
);
}
// components/OptimizedImage.tsx
import Image from 'next/image';
export function ProductImage({ src, alt, priority = false }) {
return (
<Image
src={src}
alt={alt}
width={600}
height={400}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={priority}
placeholder="blur"
blurDataURL={generateBlurPlaceholder(src)}
/>
);
}
// Use responsive sizes properly
export function Gallery({ images }) {
return (
<div className="gallery">
{images.map((img) => (
<Image
key={img.id}
src={img.src}
alt={img.alt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>
))}
</div>
);
}
Default to Server Components — Only use client components when necessary for interactivity.
Minimize Client Bundle — Import server-only modules at the top level.
Use Streaming Strategically — Wrap slow data fetches in Suspense boundaries.
Implement Proper Caching — Use appropriate cache strategies for each data type.
Optimize Images and Fonts — Use Next.js built-in optimizations.
Monitor Core Web Vitals — Track LCP, FID, and CLS in production.
Use Edge Functions Judiciously — They're fast but have limits on computation.
Plan for Incremental Migration — You don't need to convert everything at once.