Next.js Performance Optimization: SSR, SSG, and Beyond
Next.js Performance Optimization: SSR, SSG, and BeyondDecember 10, 2023

Next.js Performance Optimization: SSR, SSG, and Beyond

Comprehensive guide to Next.js performance optimization. Covers Server-Side Rendering, Static Site Generation, Image optimization, Core Web Vitals improvement, and deployment best practices.

Table of Contents

  1. Choosing a Rendering Strategy
  2. SSR Without Paying Twice
  3. SSG and ISR
  4. Images, Fonts, and Third Parties
  5. Core Web Vitals in Next.js
  6. Caching and Data Fetching
  7. Deployment Checklist

Choosing a Rendering Strategy {#rendering}

Next.js is fast when you pick the right rendering mode per route. Using SSR for a marketing page is wasted work. Using SSG for a personalized cart is a stale-data bug.

Route typeDefault choiceWhy
Marketing, docs, blogSSGHTML is ready at the edge
Product listing that changes hourlyISRStatic speed, periodic refresh
Account, checkout, search resultsSSR or client fetchPersonalized, always fresh
Admin dashboardsCSR behind authHeavy interactivity, low SEO need

On a medical e-commerce rebuild, catalog and content pages moved to SSG/ISR, while cart and checkout stayed dynamic. That split was the largest win after images.

SSR Without Paying Twice {#ssr}

Server rendering only helps if the HTML is useful before hydration. If the server sends a spinner and the client fetches everything again, you paid for SSR and still blocked LCP.

// app/products/[slug]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await getProduct(params.slug);

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.priceLabel}</p>
      <AddToCart productId={product.id} />
    </main>
  );
}

Keep client components small. AddToCart hydrates; the title and price should not wait for JavaScript.

Streaming

For pages that need several sources, stream the shell first:

import { Suspense } from 'react';

export default function ProductPage({ params }) {
  return (
    <>
      <ProductHeader slug={params.slug} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews slug={params.slug} />
      </Suspense>
    </>
  );
}

Users see the product immediately; reviews fill in without blocking LCP.

SSG and ISR {#ssg}

Static generation is the cheapest performance feature Next.js offers.

export async function generateStaticParams() {
  const products = await getTopProducts();
  return products.map((product) => ({ slug: product.slug }));
}

export const revalidate = 3600; // ISR: refresh hourly

Use generateStaticParams for the head of the catalog (top sellers, indexed SKUs). Fall back to on-demand rendering for long-tail products so build time stays bounded.

On-demand revalidation after a CMS publish is better than a 24-hour wait:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';

export async function POST(request: Request) {
  const { path, secret } = await request.json();
  if (secret !== process.env.REVALIDATE_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }
  revalidatePath(path);
  return Response.json({ revalidated: true });
}

Images, Fonts, and Third Parties {#assets}

Images

next/image only helps if you give it sizes and avoid shipping a 4000px hero.

import Image from 'next/image';

<Image
  src={product.hero}
  alt={product.name}
  width={1200}
  height={800}
  sizes="(max-width: 768px) 100vw, 50vw"
  priority
/>

Use priority only for LCP images. Lazy-load the rest. Prefer AVIF/WebP via the image optimizer.

Fonts

import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

Self-hosting with next/font removes a render-blocking round trip to Google Fonts.

Third parties

Analytics, chat widgets, and A/B tools often cost more than your app code. Load them after idle, gate them behind consent, and keep them off the critical path.

Core Web Vitals in Next.js {#cwv}

The numbers we actually moved on a production storefront:

  • LCP: 3.1s → 1.2s (SSR HTML + optimized hero + fewer blocking scripts)
  • Fully loaded: 8.4s → 1.3s
  • Page weight: −85%
  • HTTP requests: −60%

What moved LCP:

  1. Server-render the hero and title, not a client skeleton
  2. Preload the LCP image
  3. Cut CSS that blocked first paint
  4. Defer non-critical JS (chat, reviews, carousels)

What moved INP:

  1. Smaller hydration surface
  2. Break up checkout validation work
  3. Avoid layout thrash in sticky headers

Caching and Data Fetching {#data}

In the App Router, fetch is cached by default. That is great for product pages and a footgun for user sessions.

const product = await fetch(`${API}/products/${slug}`, {
  next: { revalidate: 300, tags: [`product:${slug}`] },
});

const session = await fetch(`${API}/me`, {
  cache: 'no-store',
});

Tag-based revalidation is cleaner than a global TTL when merchandisers update a single SKU.

Deployment Checklist {#deploy}

  • Enable compression and HTTP/2 at the edge
  • Set cache headers for /_next/static to immutable
  • Measure LCP on mobile 4G, not only on desktop Wi-Fi
  • Fail CI when JS budget or Lighthouse performance drops
  • Keep a rollback path: ISR pages should survive origin blips via stale-while-revalidate

Next.js performance is mostly about what you render, when you hydrate, and what you refuse to ship. Framework defaults help, but route-level decisions decide whether the site feels instant.