React
7 minutes readOptimizing Next.js Performance with Evidence, Not Guesswork
Use Core Web Vitals, bundle analysis, image sizing, and client-boundary audits to find bottlenecks and prove that a change helped.

Performance optimization should be evidence-led. First establish a reproducible baseline, then identify whether the bottleneck is network transfer, server response, image/font loading, JavaScript execution, or layout stability. This guide covers practical Next.js techniques and explains when each one helps—and when it does not.
1. Understand the metrics before optimizing
LCP measures when the largest visible content element appears; INP reflects responsiveness across real interactions; CLS measures unexpected layout movement. TTFB helps diagnose the wait before the first response byte, while JavaScript bundle size alone does not tell you how much code the browser executes. A high Lighthouse score is useful, but field data and the actual user journey matter too.
2. Create a reliable baseline
Measure a production build with the same route, viewport, device profile, network throttling, and browser version. Run multiple trials and compare the median because lab measurements vary. In DevTools, use Network to identify large or delayed resources, Performance to inspect long tasks and scripting, and Lighthouse for a repeatable audit. Keep a short record of the hypothesis and result.
# Compare like-for-like runs
1. Run a production build, not only `next dev`.
2. Record route, viewport, device profile, network, and build hash.
3. Run Lighthouse 3-5 times and compare the median, not the best score.
4. Inspect LCP element, long tasks, layout shifts, and network waterfall.
5. Change one bottleneck, repeat the same test, and document the result.3. Serve local WOFF2 fonts with next/font/local
WOFF2 is usually a good web-font delivery format because it compresses font data efficiently. Prefer a local subset containing the scripts and weights you actually use; avoid loading every weight and an entire multilingual family if the page needs only two weights. next/font/local self-hosts and builds font CSS, helping avoid a separate third-party font request. display: "swap" keeps text visible while the font loads; preload should be reserved for fonts needed early on the page. Use the actual filenames and weights in your project.
import localFont from "next/font/local";
const siteFont = localFont({
src: [
{ path: "./fonts/Inter-Regular.woff2", weight: "400", style: "normal" },
{ path: "./fonts/Inter-Medium.woff2", weight: "500", style: "normal" },
{ path: "./fonts/Inter-Bold.woff2", weight: "700", style: "normal" },
],
display: "swap",
variable: "--font-site",
preload: true,
});
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="en" className={siteFont.variable}><body>{children}</body></html>;
}4. Connect the font to your design tokens
Define the CSS variable once at the root and reuse it in the design system. If you use Tailwind CSS v4, map the variable through @theme inline as shown. Check that the selected font file actually contains Persian or Arabic glyphs when those locales are enabled; a missing glyph causes fallback fonts and can change text metrics.
@theme inline {
--font-sans: var(--font-site);
}
body {
font-family: var(--font-site), system-ui, sans-serif;
}5. Configure and run the bundle analyzer
Install @next/bundle-analyzer as a development dependency and wrap the Next config. The environment flag keeps analysis opt-in, so ordinary builds do not generate analyzer reports. On Windows, cross-env makes the script portable; alternatively set the environment variable using your shell’s syntax. Run the analyzer against a production build, not the dev server.
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = {};
export default withBundleAnalyzer(nextConfig);{
"scripts": {
"dev": "next dev",
"build": "next build",
"analyze": "cross-env ANALYZE=true next build"
}
}
# Install:
npm install -D @next/bundle-analyzer cross-env
# Run on Windows, macOS, or Linux:
npm run analyze6. How to read the analyzer output
The report is a treemap: each rectangle represents a module and its area approximates its contribution to the analyzed bundle. Look for unexpectedly large libraries, duplicate versions, locale data, icon packs, and dependencies imported by a Client Component. The client bundle report is especially important for browser JavaScript. Follow the import chain to find which component pulled a dependency in; do not delete a library only because its rectangle is large—confirm whether it is needed and whether a smaller import path exists.
7. Split heavy features with dynamic import
A chart editor, rich text editor, map, or PDF viewer may be unnecessary until the user opens a panel. next/dynamic can split that code into a separate chunk. This reduces the initial download only if the feature is not immediately needed. Add a useful loading state, and test the waterfall: too many tiny chunks or a delayed above-the-fold chart can make the experience worse.
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./heavy-chart"), {
loading: () => <div aria-busy="true">Loading chart…</div>,
});
export function AnalyticsPanel() {
return <section><h2>Analytics</h2><HeavyChart /></section>;
}8. useMemo, useCallback, and memo: measure before adding them
useMemo caches a computed value between renders while dependencies remain equal; useCallback keeps a function reference stable; memo can skip rendering a child when its props are unchanged. In the example, filtering may be worth memoizing for a large list, and a stable callback helps the memoized row. For a tiny list, the extra complexity may cost more than it saves. These hooks do not reduce the downloaded bundle and are not a substitute for profiling.
"use client";
import { memo, useCallback, useMemo, useState } from "react";
type Product = { id: string; name: string; price: number };
const ProductRow = memo(function ProductRow({ product, onSelect }: {
product: Product; onSelect: (id: string) => void;
}) {
return <button onClick={() => onSelect(product.id)}>{product.name} — {product.price}</button>;
});
export function ProductList({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
return products.filter((p) => p.name.toLowerCase().includes(normalized));
}, [products, query]);
const handleSelect = useCallback((id: string) => {
console.log("selected", id);
}, []);
return <>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search products" />
{filtered.map((product) => <ProductRow key={product.id} product={product} onSelect={handleSelect} />)}
</>;
}9. Images, LCP, and layout stability
Use next/image with known dimensions or a stable aspect ratio so the browser can reserve space. Set sizes to match the rendered width; otherwise responsive users may download oversized assets. Prioritize only the likely LCP image, not every image. Convert assets to modern formats where useful and inspect the network waterfall to confirm that image delivery is the bottleneck before changing formats.
import Image from "next/image";
export function Hero() {
return <Image
src="/images/hero.webp"
alt="Product dashboard preview"
width={1600}
height={900}
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>;
}10. A practical optimization order
- Remove unnecessary client boundaries and large client-only dependencies.
- Optimize the LCP image and self-host only the required font weights.
- Use static rendering or revalidation for public content where freshness allows it.
- Split optional heavy widgets; do not defer critical above-the-fold content.
- Use useMemo/useCallback only for measured render or computation bottlenecks.
- Run the same production test again and document the before/after result.