Server-Side Rendering (SSR) in Next.js
Server-Side Rendering (SSR) renders pages on-demand for each request, providing fresh data and dynamic content. This lesson covers dynamic rendering, streaming, React Suspense boundaries, loading states, and performance optimization techniques.
What is SSR? Server-Side Rendering generates HTML on the server for each request. Unlike static generation, SSR ensures users always see the most up-to-date content, making it ideal for personalized or frequently changing data.
Opting Into Dynamic Rendering
Pages become dynamically rendered when they use certain features or opt out of caching:
1. Using Dynamic Functions
// app/dashboard/page.tsx
import { cookies, headers } from 'next/headers';
// This page is dynamically rendered because it uses cookies()
export default async function DashboardPage() {
const cookieStore = cookies();
const theme = cookieStore.get('theme');
return (
<div>
<h1>Dashboard</h1>
<p>Current theme: {theme?.value || 'default'}</p>
</div>
);
}
Dynamic functions that trigger SSR:
cookies() - Read or set cookies
headers() - Read request headers
searchParams - Access URL search parameters in page components
fetch() with cache: 'no-store' or next: { revalidate: 0 }
2. Using Search Params
// app/search/page.tsx
// Dynamically rendered because it uses searchParams
export default async function SearchPage({
searchParams
}: {
searchParams: { q?: string; filter?: string }
}) {
const query = searchParams.q || '';
const filter = searchParams.filter || 'all';
// Fetch results based on search params
const results = await fetch(
`https://api.example.com/search?q=${query}&filter=${filter}`,
{ cache: 'no-store' }
).then(res => res.json());
return (
<div>
<h1>Search Results for: {query}</h1>
<p>Filter: {filter}</p>
{results.length === 0 ? (
<p>No results found</p>
) : (
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}
3. Setting Dynamic Route Config
// app/profile/page.tsx
// Force dynamic rendering
export const dynamic = 'force-dynamic';
export default async function ProfilePage() {
const user = await getCurrentUser();
return (
<div>
<h1>{user.name}'s Profile</h1>
<p>Email: {user.email}</p>
<p>Last login: {new Date(user.lastLogin).toLocaleString()}</p>
</div>
);
}
Dynamic Config Options:
'auto' (default) - Automatically determine based on usage
'force-dynamic' - Force dynamic rendering
'force-static' - Force static rendering (may cause errors if dynamic features are used)
'error' - Throw error if page tries to use dynamic features
Streaming with React Suspense
Streaming allows you to progressively render UI, sending content to the client as it becomes ready rather than waiting for all data to load:
Basic Suspense Usage
// app/posts/page.tsx
import { Suspense } from 'react';
import PostsList from '@/components/PostsList';
import PostsListSkeleton from '@/components/PostsListSkeleton';
export default function PostsPage() {
return (
<div>
<h1>Blog Posts</h1>
{/* Page shell renders immediately */}
<Suspense fallback={<PostsListSkeleton />}>
{/* PostsList streams in when data is ready */}
<PostsList />
</Suspense>
</div>
);
}
// components/PostsList.tsx
async function PostsList() {
// This data fetching happens in the background
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</li>
))}
</ul>
);
}
How Streaming Works:
- Server sends initial HTML shell immediately (h1, Suspense fallback)
- User sees loading skeleton while data fetches in the background
- When data is ready, server streams the actual content
- React hydrates and replaces the fallback with real content
Multiple Suspense Boundaries
// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserProfile from '@/components/UserProfile';
import RecentActivity from '@/components/RecentActivity';
import Analytics from '@/components/Analytics';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Each section can stream independently */}
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
<div className="grid">
<Suspense fallback={<div>Loading activity...</div>}>
<RecentActivity />
</Suspense>
<Suspense fallback={<div>Loading analytics...</div>}>
<Analytics />
</Suspense>
</div>
</div>
);
}
Granular Loading: Multiple Suspense boundaries allow different parts of your page to load independently. Fast data appears quickly while slow data streams in later, improving perceived performance.
Nested Suspense Boundaries
// app/posts/[id]/page.tsx
import { Suspense } from 'react';
import PostContent from '@/components/PostContent';
import PostComments from '@/components/PostComments';
import RelatedPosts from '@/components/RelatedPosts';
export default function PostPage({ params }: { params: { id: string } }) {
return (
<article>
{/* Critical content loads first */}
<Suspense fallback={<div>Loading post...</div>}>
<PostContent id={params.id} />
{/* Less critical content can load after */}
<Suspense fallback={<div>Loading comments...</div>}>
<PostComments postId={params.id} />
</Suspense>
</Suspense>
{/* Related posts load last */}
<Suspense fallback={<div>Loading related posts...</div>}>
<RelatedPosts postId={params.id} />
</Suspense>
</article>
);
}
Loading States
Next.js provides a special loading.tsx file for automatic loading UI:
// app/posts/loading.tsx
export default function Loading() {
return (
<div className="loading-container">
<div className="skeleton">
<div className="skeleton-title"></div>
<div className="skeleton-text"></div>
<div className="skeleton-text"></div>
<div className="skeleton-text"></div>
</div>
</div>
);
}
// app/posts/page.tsx
// loading.tsx automatically wraps this in Suspense
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return (
<div>
<h1>Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
loading.tsx Behavior: Next.js automatically wraps your page in a Suspense boundary with loading.tsx as the fallback. This is equivalent to manually wrapping the page with Suspense.
Personalized Content with SSR
SSR is ideal for user-specific content that can't be cached:
// app/profile/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
async function getUser() {
const cookieStore = cookies();
const sessionToken = cookieStore.get('session');
if (!sessionToken) {
redirect('/login');
}
const res = await fetch('https://api.example.com/user', {
headers: {
Authorization: `Bearer ${sessionToken.value}`
},
cache: 'no-store' // Never cache user-specific data
});
return res.json();
}
export default async function ProfilePage() {
const user = await getUser();
return (
<div>
<h1>Welcome, {user.name}!</h1>
<div>
<h2>Account Details</h2>
<p>Email: {user.email}</p>
<p>Member since: {new Date(user.createdAt).toLocaleDateString()}</p>
<p>Account type: {user.subscription}</p>
</div>
<div>
<h2>Recent Orders</h2>
<ul>
{user.orders.map(order => (
<li key={order.id}>
Order #{order.id} - ${order.total}
</li>
))}
</ul>
</div>
</div>
);
}
Dynamic Data with Parallel Loading
Fetch multiple data sources in parallel for better performance:
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function getUser() {
const res = await fetch('https://api.example.com/user', {
cache: 'no-store'
});
return res.json();
}
async function getNotifications() {
const res = await fetch('https://api.example.com/notifications', {
cache: 'no-store'
});
return res.json();
}
async function getStats() {
const res = await fetch('https://api.example.com/stats', {
cache: 'no-store'
});
return res.json();
}
// Components that fetch data independently
async function UserSection() {
const user = await getUser();
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
async function NotificationsSection() {
const notifications = await getNotifications();
return (
<div>
<h2>Notifications ({notifications.length})</h2>
{/* Render notifications */}
</div>
);
}
async function StatsSection() {
const stats = await getStats();
return (
<div>
<h2>Statistics</h2>
<p>Views: {stats.views}</p>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* All three sections load in parallel */}
<Suspense fallback={<div>Loading user...</div>}>
<UserSection />
</Suspense>
<Suspense fallback={<div>Loading notifications...</div>}>
<NotificationsSection />
</Suspense>
<Suspense fallback={<div>Loading stats...</div>}>
<StatsSection />
</Suspense>
</div>
);
}
Performance Benefit: With Suspense, all three data fetches start simultaneously. The page doesn't wait for the slowest request—each section appears as soon as its data is ready.
Error Handling in SSR
Handle errors gracefully with error boundaries:
// app/posts/error.tsx
'use client'; // Error boundaries must be Client Components
import { useEffect } from 'react';
export default function Error({
error,
reset
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>
Try again
</button>
</div>
);
}
// app/posts/page.tsx
export default async function PostsPage() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store'
});
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
const posts = await res.json();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
Cache Control Headers
Control caching behavior at the CDN/browser level:
// app/api/posts/route.ts
export async function GET() {
const posts = await fetchPosts();
return Response.json(posts, {
headers: {
// Cache for 60 seconds, revalidate in background
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30'
}
});
}
// app/feed/route.ts
export async function GET() {
const feed = await generateFeed();
return new Response(feed, {
headers: {
'Content-Type': 'application/xml',
// No caching for personalized feeds
'Cache-Control': 'private, no-cache, no-store, must-revalidate'
}
});
}
Performance Optimization
1. Preloading Data
// lib/data.ts
import { cache } from 'react';
// Deduplicate requests across components
export const getPost = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: 'no-store'
});
return res.json();
});
// Preload function
export function preloadPost(id: string) {
void getPost(id); // Start fetching without awaiting
}
// app/posts/[id]/layout.tsx
import { preloadPost } from '@/lib/data';
export default function PostLayout({
children,
params
}: {
children: React.ReactNode;
params: { id: string };
}) {
// Start loading data early
preloadPost(params.id);
return <div>{children}</div>;
}
2. Partial Prerendering (Experimental)
// next.config.js
module.exports = {
experimental: {
ppr: true // Enable Partial Prerendering
}
};
// app/dashboard/page.tsx
// Static shell with dynamic content
export default function DashboardPage() {
return (
<div>
{/* Static parts render immediately */}
<header>
<h1>Dashboard</h1>
<nav>{/* Navigation */}</nav>
</header>
{/* Dynamic parts stream in */}
<Suspense fallback={<Skeleton />}>
<DynamicContent />
</Suspense>
</div>
);
}
PPR Note: Partial Prerendering is an experimental feature that combines static and dynamic rendering in the same page. The static shell is prerendered while dynamic parts stream in via Suspense.
Practice Exercise
Task: Build a user dashboard with SSR and streaming:
- Create a dashboard page that fetches user-specific data
- Implement multiple Suspense boundaries for different sections
- Add loading skeletons for each section
- Create an error boundary to handle fetch failures
- Implement parallel data fetching for 3+ data sources
- Add authentication check using cookies()
- Optimize with preloading in the layout
Requirements:
- Use TypeScript with proper types
- Force dynamic rendering with cache: 'no-store'
- Create distinct loading states for each section
- Handle errors gracefully with user-friendly messages
- Implement proper authentication flow with redirects
Bonus Challenge: Implement a real-time notification section that fetches new data every 30 seconds using Client Component with Server Actions for data updates.
Summary
- SSR renders pages on-demand for each request with fresh data
- Use dynamic functions like cookies(), headers(), searchParams to trigger SSR
- Force dynamic rendering with export const dynamic = 'force-dynamic'
- Streaming with Suspense allows progressive rendering of content
- Multiple Suspense boundaries enable granular loading states
- loading.tsx files provide automatic loading UI for route segments
- SSR is ideal for personalized content that can't be cached
- Parallel data fetching with Suspense improves perceived performance
- Error boundaries handle failures gracefully
- Preloading and caching strategies optimize SSR performance