Comprehensive guide to Next.js performance optimization. Covers Server-Side Rendering, Static Site Generation, Image optimization, Core Web Vitals improvement, and deployment best practices.
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 type | Default choice | Why |
|---|---|---|
| Marketing, docs, blog | SSG | HTML is ready at the edge |
| Product listing that changes hourly | ISR | Static speed, periodic refresh |
| Account, checkout, search results | SSR or client fetch | Personalized, always fresh |
| Admin dashboards | CSR behind auth | Heavy 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.
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.
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.
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 });
}
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.
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.
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.
The numbers we actually moved on a production storefront:
What moved LCP:
What moved INP:
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.
/_next/static to immutableNext.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.