Banner Background

The Complete Guide to Server-Side Rendering in Next.js

Master Server-Side Rendering with Next.js. Learn SSR, SSG, ISR, and the App Router to build lightning-fast, SEO-friendly web applications.

Introduction

Next.js has revolutionized how we build React applications by bringing server-side rendering to the masses. With the introduction of the App Router and React Server Components, the framework has evolved to offer even more powerful rendering strategies.

Why Server-Side Rendering?

SSR improves SEO, reduces time to first byte, and provides better performance on slower devices by sending pre-rendered HTML to the client.

Rendering Strategies Overview

Next.js offers multiple rendering strategies, each suited for different use cases:

Server-Side Rendering (SSR)

Renders pages on each request. Perfect for dynamic, personalized content.

  • ✅ Always fresh data
  • ✅ Personalized content
  • ❌ Slower than static

Static Site Generation (SSG)

Generates pages at build time. Ideal for content that doesn't change often.

  • ✅ Lightning fast
  • ✅ Great for SEO
  • ❌ Stale until rebuild

Incremental Static Regeneration (ISR)

Updates static pages after deployment without full rebuild.

  • ✅ Fast like static
  • ✅ Fresh content
  • ✅ Best of both worlds

Client-Side Rendering (CSR)

Renders in the browser. Good for authenticated, interactive sections.

  • ✅ Highly interactive
  • ✅ No server load
  • ❌ Slower initial load

App Router vs Pages Router

The new App Router (Next.js 13+) uses React Server Components by default:

app/page.tsx
// This is a Server Component by default
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    cache: 'no-store' // SSR
  });
  const posts = await data.json();
  
  return (
    <div>
      <h1>Latest Posts</h1>
      {posts.map(post => (
        <Article key={post.id} post={post} />
      ))}
    </div>
  );
}

Benefits:

  • Server Components by default
  • Improved data fetching
  • Nested layouts
  • Streaming and Suspense
  • Better performance

The classic Pages Router uses getServerSideProps:

pages/index.tsx
import { GetServerSideProps } from 'next';

interface Props {
  posts: Post[];
}

export default function Home({ posts }: Props) {
  return (
    <div>
      <h1>Latest Posts</h1>
      {posts.map(post => (
        <Article key={post.id} post={post} />
      ))}
    </div>
  );
}

export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();
  
  return {
    props: { posts },
  };
};

When to use:

  • Existing projects
  • Gradual migration
  • Familiar patterns
FeatureApp RouterPages Router
Server Components✅ Default❌ No
Data FetchingAsync/awaitgetServerSideProps
Layouts✅ NestedSingle _app.tsx
Loading UI✅ loading.tsxManual
Error Handling✅ error.tsxManual
Streaming✅ Native❌ No
Performance⚡ Better✓ Good
Learning CurveModerateEasy

Migration Note

You can use both routers in the same project during migration, but they can't share the same route. Plan your migration strategy carefully.

Server-Side Rendering (SSR)

How SSR Works

User Requests Page

Browser sends request to Next.js server

Server Fetches Data

Server fetches data from API/database

Server Renders HTML

React renders component to HTML

HTML Sent to Client

Pre-rendered HTML sent to browser

Hydration

React makes page interactive

Implementing SSR

app/products/[id]/page.tsx
interface PageProps {
  params: { id: string };
}

// Force dynamic rendering (SSR)
export const dynamic = 'force-dynamic';

export default async function ProductPage({ params }: PageProps) {
  // This runs on the server for each request
  const product = await fetch(
    `https://api.example.com/products/${params.id}`,
    { cache: 'no-store' }
  ).then(res => res.json());
  
  const reviews = await fetch(
    `https://api.example.com/products/${params.id}/reviews`,
    { cache: 'no-store' }
  ).then(res => res.json());
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <Reviews data={reviews} />
    </div>
  );
}

// Generate metadata server-side
export async function generateMetadata({ params }: PageProps) {
  const product = await fetch(
    `https://api.example.com/products/${params.id}`
  ).then(res => res.json());
  
  return {
    title: product.name,
    description: product.description,
  };
}
pages/products/[id].tsx
import { GetServerSideProps } from 'next';

interface Props {
  product: Product;
  reviews: Review[];
}

export default function ProductPage({ product, reviews }: Props) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <Reviews data={reviews} />
    </div>
  );
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { id } = context.params!;
  
  // Fetch product and reviews in parallel
  const [productRes, reviewsRes] = await Promise.all([
    fetch(`https://api.example.com/products/${id}`),
    fetch(`https://api.example.com/products/${id}/reviews`),
  ]);
  
  const [product, reviews] = await Promise.all([
    productRes.json(),
    reviewsRes.json(),
  ]);
  
  return {
    props: {
      product,
      reviews,
    },
  };
};
app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export default async function DashboardPage() {
  const session = await getServerSession(authOptions);
  
  // Redirect if not authenticated
  if (!session) {
    redirect('/login');
  }
  
  // Fetch user-specific data
  const userData = await fetch(
    `https://api.example.com/users/${session.user.id}/data`,
    {
      headers: {
        Authorization: `Bearer ${session.accessToken}`,
      },
      cache: 'no-store',
    }
  ).then(res => res.json());
  
  return (
    <div>
      <h1>Welcome, {session.user.name}!</h1>
      <UserData data={userData} />
    </div>
  );
}

Parallel Data Fetching

Always fetch independent data in parallel using Promise.all() to minimize server response time.

Static Site Generation (SSG)

Perfect for content that doesn't change frequently, like blog posts or product pages.

Project Structure

page.tsx
layout.tsx

Implementation

app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

interface PageProps {
  params: { slug: string };
}

// Generate static pages at build time
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());
  
  return posts.map((post: Post) => ({
    slug: post.slug,
  }));
}

// Static by default in App Router
export default async function BlogPost({ params }: PageProps) {
  const post = await fetch(
    `https://api.example.com/posts/${params.slug}`
  ).then(res => res.json());
  
  if (!post) {
    notFound();
  }
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// Generate metadata for SEO
export async function generateMetadata({ params }: PageProps) {
  const post = await fetch(
    `https://api.example.com/posts/${params.slug}`
  ).then(res => res.json());
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}
pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

interface Props {
  post: Post;
}

export default function BlogPost({ post }: Props) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());
  
  const paths = posts.map((post: Post) => ({
    params: { slug: post.slug },
  }));
  
  return {
    paths,
    fallback: 'blocking', // or false or true
  };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetch(
    `https://api.example.com/posts/${params!.slug}`
  ).then(res => res.json());
  
  if (!post) {
    return {
      notFound: true,
    };
  }
  
  return {
    props: { post },
    revalidate: 3600, // ISR: Revalidate every hour
  };
};
app/blog/[slug]/page.tsx
import { compileMDX } from 'next-mdx-remote/rsc';
import fs from 'fs/promises';
import path from 'path';

const postsDirectory = path.join(process.cwd(), 'content');

export async function generateStaticParams() {
  const files = await fs.readdir(postsDirectory);
  
  return files
    .filter(file => file.endsWith('.mdx'))
    .map(file => ({
      slug: file.replace(/\.mdx$/, ''),
    }));
}

export default async function BlogPost({ params }: PageProps) {
  const filePath = path.join(postsDirectory, `${params.slug}.mdx`);
  const source = await fs.readFile(filePath, 'utf8');
  
  const { content, frontmatter } = await compileMDX({
    source,
    options: { parseFrontmatter: true },
    components: {
      // Custom components
      CodeBlock,
      Image: CustomImage,
    },
  });
  
  return (
    <article>
      <h1>{frontmatter.title}</h1>
      <time>{frontmatter.date}</time>
      {content}
    </article>
  );
}

Fallback Options

  • false: Only generated paths accessible, 404 for others
  • true: Generate on-demand, show loading state
  • 'blocking': Generate on-demand, wait before showing page

Incremental Static Regeneration (ISR)

ISR combines the best of SSG and SSR: static speed with dynamic freshness.

How ISR Works

Initial Build

Pages generated at build time like SSG

Serve Stale

Cached page served instantly

Revalidation

After specified time, regenerate in background

Update Cache

New version cached and served to subsequent users

Implementation

app/products/[id]/page.tsx
interface PageProps {
  params: { id: string };
}

export const revalidate = 60; // Revalidate every 60 seconds

export default async function ProductPage({ params }: PageProps) {
  const product = await fetch(
    `https://api.example.com/products/${params.id}`,
    {
      next: { 
        revalidate: 60, // Can override per-request
        tags: ['products'] // For on-demand revalidation
      }
    }
  ).then(res => res.json());
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
      <p>Last updated: {new Date().toLocaleString()}</p>
    </div>
  );
}

export async function generateStaticParams() {
  const products = await fetch('https://api.example.com/products')
    .then(res => res.json());
  
  // Generate first 100 products at build time
  return products.slice(0, 100).map((product: Product) => ({
    id: product.id,
  }));
}
pages/products/[id].tsx
interface Props {
  product: Product;
}

export default function ProductPage({ product }: Props) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
      <p>Last updated: {new Date().toLocaleString()}</p>
    </div>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const products = await fetch('https://api.example.com/products')
    .then(res => res.json());
  
  const paths = products.slice(0, 100).map((product: Product) => ({
    params: { id: product.id },
  }));
  
  return {
    paths,
    fallback: 'blocking', // Generate other pages on-demand
  };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const product = await fetch(
    `https://api.example.com/products/${params!.id}`
  ).then(res => res.json());
  
  return {
    props: { product },
    revalidate: 60, // Revalidate every 60 seconds
  };
};
app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');
  
  // Validate secret token
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }
  
  const body = await request.json();
  
  try {
    // Revalidate by tag
    if (body.tag) {
      revalidateTag(body.tag);
      return NextResponse.json({ revalidated: true, tag: body.tag });
    }
    
    // Revalidate by path
    if (body.path) {
      revalidatePath(body.path);
      return NextResponse.json({ revalidated: true, path: body.path });
    }
    
    return NextResponse.json({ 
      message: 'Missing tag or path' 
    }, { status: 400 });
  } catch (err) {
    return NextResponse.json({ 
      message: 'Error revalidating' 
    }, { status: 500 });
  }
}

Trigger revalidation:

curl -X POST \
  'http://localhost:3000/api/revalidate?secret=YOUR_SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"tag":"products"}'

When to Use ISR

ISR is perfect for e-commerce product pages, blog posts, news articles, and any content that updates occasionally but needs to be fast.

Streaming and Suspense

The App Router supports streaming with React Suspense for progressive rendering.

app/dashboard/page.tsx
import { Suspense } from 'react';

// Fast component
async function UserInfo() {
  const user = await fetchUser(); // Fast query
  return <div>Welcome, {user.name}!</div>;
}

// Slow component
async function Analytics() {
  const data = await fetchAnalytics(); // Slow query
  return <AnalyticsChart data={data} />;
}

export default function Dashboard() {
  return (
    <div>
      {/* Show user info immediately */}
      <Suspense fallback={<UserSkeleton />}>
        <UserInfo />
      </Suspense>
      
      {/* Stream analytics when ready */}
      <Suspense fallback={<ChartSkeleton />}>
        <Analytics />
      </Suspense>
    </div>
  );
}
app/products/page.tsx
import { Suspense } from 'react';

async function FeaturedProducts() {
  const products = await fetchFeatured();
  return <ProductGrid products={products} />;
}

async function RecentReviews() {
  const reviews = await fetchRecentReviews();
  return <ReviewsList reviews={reviews} />;
}

async function Recommendations() {
  const recommendations = await fetchRecommendations();
  return <ProductCarousel products={recommendations} />;
}

export default function ProductsPage() {
  return (
    <div>
      <h1>Products</h1>
      
      <Suspense fallback={<ProductGridSkeleton />}>
        <FeaturedProducts />
      </Suspense>
      
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<ReviewsSkeleton />}>
          <RecentReviews />
        </Suspense>
        
        <Suspense fallback={<CarouselSkeleton />}>
          <Recommendations />
        </Suspense>
      </div>
    </div>
  );
}
app/dashboard/loading.tsx
// Automatically used as Suspense fallback
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 bg-gray-200 rounded w-1/4"></div>
      <div className="h-64 bg-gray-200 rounded"></div>
      <div className="grid grid-cols-3 gap-4">
        <div className="h-32 bg-gray-200 rounded"></div>
        <div className="h-32 bg-gray-200 rounded"></div>
        <div className="h-32 bg-gray-200 rounded"></div>
      </div>
    </div>
  );
}

Performance Optimization

Code Splitting

Dynamic Imports

import dynamic from 'next/dynamic';

const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <Spinner />,
  ssr: false, // Don't render on server
});

Route-Based Splitting

Next.js automatically splits code by route

Component-Based Splitting

Split large components into separate bundles

Caching Strategies

// Force static (cached)
export const dynamic = 'force-static';

// Force dynamic (no cache)
export const dynamic = 'force-dynamic';

// Per-request caching
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // Default
  // OR
  cache: 'no-store', // Don't cache
  // OR
  next: { revalidate: 3600 }, // ISR
});
// API route with caching headers
export async function GET() {
  const data = await fetchData();
  
  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
    },
  });
}
import { unstable_cache } from 'next/cache';

// Cache database queries
const getCachedUser = unstable_cache(
  async (userId: string) => {
    return await db.user.findUnique({
      where: { id: userId },
    });
  },
  ['user-by-id'],
  {
    revalidate: 3600,
    tags: ['users'],
  }
);

export default async function UserProfile({ userId }: Props) {
  const user = await getCachedUser(userId);
  return <Profile user={user} />;
}

Cache Carefully

Be cautious with caching user-specific data. Always consider privacy and data freshness requirements.

SEO Optimization

Metadata API

app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s | My App',
  },
  description: 'My awesome application',
  keywords: ['Next.js', 'React', 'JavaScript'],
  authors: [{ name: 'Your Name' }],
  openGraph: {
    title: 'My App',
    description: 'My awesome application',
    url: 'https://myapp.com',
    siteName: 'My App',
    images: [
      {
        url: 'https://myapp.com/og.jpg',
        width: 1200,
        height: 630,
      },
    ],
    locale: 'en_US',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'My App',
    description: 'My awesome application',
    images: ['https://myapp.com/og.jpg'],
  },
};

Structured Data

app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: PageProps) {
  const post = await getPost(params.slug);
  
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
    },
  };
  
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{post.title}</h1>
        {/* ... */}
      </article>
    </>
  );
}

Best Practices

  1. Choose the Right Strategy - Use SSG for static, SSR for dynamic, ISR for hybrid
  2. Optimize Data Fetching - Fetch in parallel, minimize waterfalls
  3. Cache Aggressively - But invalidate intelligently
  4. Use Streaming - Show content progressively
  5. Monitor Performance - Track TTFB, FCP, LCP
  6. SEO First - Leverage SSR for better search rankings
  7. Type Safety - Use TypeScript for better DX
  8. Error Handling - Graceful fallbacks for failed requests

Performance Budget

Set performance budgets: TTFB < 600ms, FCP < 1.8s, LCP < 2.5s. Monitor with tools like Lighthouse and Web Vitals.

Conclusion

Next.js provides powerful rendering strategies for every use case. The App Router with Server Components represents the future of React, offering better performance and developer experience.

Start with SSG where possible, use ISR for semi-dynamic content, and reserve SSR for truly personalized experiences.

Resources


Questions about Next.js rendering? Drop a comment below!