Most Next.js performance comes from four decisions. Keep components on the server so less JavaScript ships and hydrates. Cache data deliberately and invalidate it when it changes, instead of rendering every request from scratch. Stream slow sections behind Suspense so the page shell and the largest content element arrive first. And let next/image and next/font handle the bytes that dominate LCP and layout shift. Then measure the result with field Core Web Vitals (LCP, INP and CLS at the 75th percentile of real users), because a Lighthouse score from a fast laptop says little about a mid-range phone on a mobile network.
Key takeaways
- Optimize for field Core Web Vitals: LCP 2.5 s or less, INP 200 ms or less, CLS 0.1 or less, at the 75th percentile. INP replaced FID in March 2024.
- Server Components are the biggest lever for INP, because code that never ships never hydrates. Keep
"use client"at the leaves. - Since Next.js 15,
fetchand GET Route Handlers are not cached by default. Decide caching per data source and invalidate by tag. - Stream everything slow, but never put the LCP element behind a slow Suspense boundary.
- Third-party scripts and oversized client bundles cause more INP regressions than your own rendering code does.
Know what you are optimizing
Core Web Vitals are measured on real users. Google's thresholds, assessed at the 75th percentile of page loads:
| Metric | Good | Poor | What usually moves it in Next.js |
|---|---|---|---|
| Largest Contentful Paint (LCP) | 2.5 s or less | over 4 s | Server response time, the hero image's priority and size, render-blocking fonts and CSS |
| Interaction to Next Paint (INP) | 200 ms or less | over 500 ms | Client JavaScript size, hydration, heavy event handlers, third-party scripts |
| Cumulative Layout Shift (CLS) | 0.1 or less | over 0.25 | Images without dimensions, font swaps, skeletons that differ in size from content, late banners |
Time to First Byte is not a Core Web Vital, but it sits inside LCP; web.dev's guidance is 0.8 seconds or less. A standard Lighthouse page load cannot measure INP because nothing interacts with the page; its lab proxy is Total Blocking Time. Lighthouse's timespan mode does report INP, but only for the interactions you record or script.
Measure in the field first
Optimize what real users experience, on the pages they visit. Next.js exposes the metrics through a hook:
// app/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
path: window.location.pathname,
});
if (!navigator.sendBeacon("/api/vitals", body)) {
fetch("/api/vitals", { method: "POST", body, keepalive: true });
}
});
return null;
}Render <WebVitals /> once in the root layout and aggregate by route and device class. When a metric is poor, the attribution build of the web-vitals library tells you which element was the LCP and which interaction was slow, which turns "INP is bad" into "the filter dropdown on /search is slow". Our observability guide covers wiring this into the same dashboards as your backend metrics.
Server Components: ship less JavaScript
In the App Router every component is a Server Component unless a file starts with "use client". Server Components render on the server, send HTML plus a compact payload, and ship none of their own code to the browser. Client Components ship their code and hydrate. Hydration is main-thread work, and main-thread work is what INP measures.
Keep the client boundary at the leaves:
// app/products/[slug]/page.tsx (Server Component)
import { notFound } from "next/navigation";
import { getProduct } from "@/lib/catalog";
import { AddToCart } from "./add-to-cart";
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<ProductDescription markdown={product.description} />
<PriceTable tiers={product.tiers} />
<AddToCart productId={product.id} />
</article>
);
}// app/products/[slug]/add-to-cart.tsx
"use client";
import { useTransition } from "react";
import { addToCart } from "./actions";
export function AddToCart({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button disabled={pending} onClick={() => startTransition(() => addToCart(productId))}>
{pending ? "Adding" : "Add to cart"}
</button>
);
}The markdown renderer and the price formatting stay on the server; only the button ships. Three rules keep it that way:
- Pass server-rendered content into client wrappers as
children. A clientTabsorCollapsiblecan wrap Server Components without turning them into client code. - Pass only the fields a Client Component needs. Props to Client Components are serialized into the page. Hand a whole database row to a client chart and every column travels in the HTML.
- Put
import "server-only"at the top of modules that touch secrets or the database. An accidental import from a Client Component then fails the build instead of leaking code into the bundle.
Fetch data without waterfalls
Sequential awaits add up. Start independent work together:
// Slow: total time is the sum of the three calls.
async function DashboardSequential({ id }: { id: string }) {
const user = await getUser(id);
const orders = await getOrders(id);
const recs = await getRecommendations(id);
return <Dashboard user={user} orders={orders} recs={recs} />;
}
// Fast: total time is the slowest call.
async function DashboardParallel({ id }: { id: string }) {
const [user, orders, recs] = await Promise.all([getUser(id), getOrders(id), getRecommendations(id)]);
return <Dashboard user={user} orders={orders} recs={recs} />;
}When the same data is needed in generateMetadata, the layout and the page, wrap the loader in React's cache so it runs once per request. Identical fetch calls are deduplicated within a render automatically; database and SDK calls are not.
import { cache } from "react";
export const getProduct = cache(async (slug: string) =>
db.product.findUnique({ where: { slug } }),
);Caching and revalidation
Caching is where most App Router performance is won or lost, and where the defaults have changed between major versions.
Defaults since Next.js 15
| What | Default | How to cache it |
|---|---|---|
fetch() in Server Components | Not cached | cache: "force-cache" or next: { revalidate: 3600, tags: ["catalog"] } |
| GET Route Handlers | Not cached | export const dynamic = "force-static" |
| Database and SDK calls | Not cached | "use cache" with Cache Components (Next.js 16), or unstable_cache on Next.js 15 |
Request-time APIs (cookies(), headers(), searchParams) | Make the route dynamic | Move them below a Suspense boundary or out of shared layouts |
A route that calls no request-time APIs is still prerendered at build time, even if its fetches are uncached. That is fast, but it also means "uncached by default" does not mean "fresh by default". Check what each route actually renders as in the build output.
Time-based and on-demand revalidation
Tag data by what it depends on, then invalidate the tag when that data changes:
// lib/catalog.ts
import { unstable_cache } from "next/cache";
export const getCatalog = unstable_cache(
async (category: string) => db.product.findMany({ where: { category } }),
["catalog"],
{ revalidate: 3600, tags: ["catalog"] },
);// app/admin/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updatePrice(id: string, price: number) {
await db.product.update({ where: { id }, data: { price } });
revalidateTag("catalog", "max");
}The interval is a safety net; the tag is how content actually stays fresh. Next.js 16 requires a cache profile as the second argument to revalidateTag, and "max" serves stale content while fresh data loads in the background; the single-argument form is deprecated. When the next read must show the change at once, call updateTag from the Server Action instead. On Next.js 15, pass the tag alone. unstable_cache still runs on Next.js 16, where "use cache" replaces it.
Cache Components in Next.js 16
Next.js 16 introduced Cache Components, enabled with cacheComponents: true in next.config. Data access becomes dynamic unless you cache it with the "use cache" directive, and the cached parts of a page form a prerendered static shell while dynamic parts stream in behind Suspense:
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
async function Catalog() {
"use cache";
cacheLife("hours");
cacheTag("catalog");
const products = await db.product.findMany();
return <ProductGrid products={products} />;
}
export default function Page() {
return (
<>
<Catalog />
<Suspense fallback={<CartSkeleton />}>
<Cart />
</Suspense>
</>
);
}Catalog is part of the instant static shell. Cart reads cookies, so it renders per request and streams in. On Next.js 15 these APIs are experimental: the directive needs the experimental.useCache or experimental.dynamicIO flag, and the helpers are imported as unstable_cacheLife and unstable_cacheTag. Treat this section as the direction of travel if you have not upgraded.
Self-hosting: share the cache
On Vercel the cache is shared across instances. Self-hosted, each instance keeps its own cache in memory and on disk by default, so three replicas can serve three different versions of a revalidated page. Point every instance at a shared store with a custom cache handler:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0,
};cache-handler.js implements get, set and revalidateTag against Redis or similar, and cacheMaxMemorySize: 0 turns off the per-instance memory cache so instances cannot disagree. Entries cached with "use cache" go through a separate cacheHandlers option. Our Docker production guide covers the rest of a self-hosted setup.
Streaming with Suspense
Streaming sends the shell immediately and fills in slow sections as their data resolves:
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<PageHeader />
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</main>
);
}Two rules make streaming help rather than hurt. First, keep the LCP element outside slow boundaries: if the hero image depends on a slow query, LCP waits for that query no matter how fast the shell arrives. Second, make every skeleton the same size as the content it replaces, or the swap becomes layout shift. A loading.tsx file wraps a whole segment in one boundary, which is a good default and a coarse one; add inner boundaries where sections have different speeds.
Images: next/image done right
The LCP element on most pages is an image. Get these right:
import Image from "next/image";
import hero from "./hero.jpg";
export function Hero() {
return (
<Image
src={hero}
alt="Deployment dashboard showing this week's releases"
preload
placeholder="blur"
sizes="(max-width: 768px) 100vw, 50vw"
/>
);
}preloadon the LCP image only. It preloads the image instead of lazy loading it. Next.js 16 deprecated the olderpriorityprop in favor ofpreload; on Next.js 15, usepriority. On more than one or two images per page it just competes for bandwidth.sizeson every responsive image. Without it, the browser assumes the image spans the full viewport and downloads a far larger file than a three-column card needs.- Static imports where you can. They supply width, height and a blur placeholder at build time, which removes the CLS risk.
fillneeds a sized parent. Afillimage inside a container with no height is a layout shift waiting to happen.- Self-hosted optimization costs CPU. The first request for each size and format is resized on your server. Put a CDN in front, or use a loader for an image service.
Fonts: next/font
next/font self-hosts font files, removes the request to a third-party font host, and generates a size-adjusted fallback font so the swap does not shift layout:
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
const sans = Inter({ subsets: ["latin"], variable: "--font-sans" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono", preload: false });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
);
}Load one variable font instead of several static weights, limit subsets to the scripts you render, and set preload: false for fonts that appear only below the fold.
Fixing INP
INP measures the delay from a user's tap or keypress to the next frame. In Next.js apps the causes are almost always one of these:
| Cause | Fix |
|---|---|
| Large Client Component trees hydrating at load | Move rendering to Server Components; lazy load below-the-fold widgets with next/dynamic |
| Expensive state updates on every keystroke | useDeferredValue or useTransition so typing stays responsive |
| Re-rendering large lists | Memoize the list (or enable React Compiler), virtualize long lists |
| Third-party tags (analytics, chat, A/B testing) | Load with next/script and strategy="lazyOnload", or the @next/third-parties components |
| Long synchronous handlers | Do the visible update first, then defer the rest |
"use client";
import { memo, useDeferredValue, useState } from "react";
const ProductGrid = memo(function ProductGrid({ products, query }: { products: Product[]; query: string }) {
const visible = products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
return <Grid items={visible} />;
});
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search products" />
<ProductGrid products={products} query={deferredQuery} />
</>
);
}The input updates immediately; the grid re-renders with the deferred value and React can interrupt that work when the next key arrives. React Compiler, stable since its 1.0 release in October 2025, applies this kind of memoization automatically, and Next.js exposes it through a reactCompiler option (inside experimental in Next.js 15).
Keep the client bundle small
- Find what is big. Next.js 16 made Turbopack the default bundler, and since Next.js 16.1
next experimental-analyzeshows its client and server modules with the import chain behind each one. For webpack builds,@next/bundle-analyzershows each client chunk's contents. - Fix barrel imports. Add internal libraries that re-export everything from one index file to
experimental.optimizePackageImportsso only the modules you use are bundled. - Lazy load heavy client-only widgets. Charts, editors and maps belong behind
next/dynamic. Note thatssr: falseis only allowed inside Client Components; Next.js 15 rejects it in Server Components. - Move formatting to the server. Date, currency and markdown libraries often end up in the client bundle only because one Client Component formats a value it could have received preformatted.
Budgets in CI
Field data tells you about last week. A lab check in CI catches the regression before it ships:
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"url": ["http://localhost:3000/", "http://localhost:3000/pricing"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
}
}
}That lighthouserc.json runs with Lighthouse CI against a production build. Lab numbers are noisy, so run several times and alert on trends rather than single runs. Layout breakage at phone widths is a separate failure from CLS but turns up in the same reviews; wontfit can run in the same pipeline and fail it when an element overflows a phone viewport.
What goes wrong in production
- One
cookies()call in the root layout makes every route dynamic. Someone reads a theme or locale cookie in the layout, and the whole site stops being prerendered. Read request-time data as deep in the tree as possible, behind Suspense. - Server Components fetching your own Route Handlers. It adds a network hop, and at build time there is no server to call. Call the underlying function directly.
- Replicas disagree after revalidation. Self-hosted instances with separate caches serve different versions. Use a shared cache handler.
- Image optimization spikes CPU after a deploy. Every size is regenerated on first request. Warm critical images or put a CDN in front.
- Huge serialized props. A Client Component receives a large array, and the page HTML doubles. Select fields on the server.
- Proxy runs on every asset request. A broad matcher puts proxy (called middleware before Next.js 16) in front of images and static files. Scope the
matcherto the routes that need it. - Tags added outside code review. A marketing tag added through a tag manager regresses INP for every page, and no pull request shows it. Watch field INP per release and per week.
Offline caching and installability are a separate layer from server rendering; our progressive web apps guide covers service worker caching for Next.js apps that need it.
Frequently Asked Questions
How do I make my Next.js app faster?
Keep components on the server so less JavaScript ships and hydrates, cache data and invalidate it when it changes, and stream slow sections behind Suspense so the page shell and main content arrive first. Use next/image with correct sizes for the LCP image and next/font to avoid layout shift. Then confirm the gains with field Core Web Vitals, not only a lab score.
What are good Core Web Vitals scores?
Google rates a page as good when, at the 75th percentile of real page loads, Largest Contentful Paint is 2.5 seconds or less, Interaction to Next Paint is 200 milliseconds or less, and Cumulative Layout Shift is 0.1 or less. INP replaced First Input Delay as a Core Web Vital in March 2024. These are field metrics, measured on real users and devices.
Does Next.js cache fetch requests by default?
Not since Next.js 15. Fetch requests and GET Route Handlers are no longer cached by default, and the client router no longer reuses page segments, so you opt in with cache options, a revalidate interval, or the use cache directive when Cache Components is enabled. A route that uses no request-time APIs is still prerendered at build time, so check what each route actually renders as.
Are React Server Components faster than client components?
They ship no component JavaScript to the browser and need no hydration, which reduces main-thread work and usually improves INP on slower phones. They are not automatically faster to render; a slow database query is just as slow on the server. The gain comes from moving rendering and data access off the client and keeping Client Components small.
Why is my Next.js page slow even though it is statically generated?
The usual causes are a large client bundle that takes long to hydrate, an unoptimized or lazily loaded hero image, web fonts causing layout shift, or third-party scripts blocking the main thread. Static generation fixes server response time, not what happens in the browser. Field data with attribution will show which element or interaction is the problem.
Need help making a Next.js app fast?
We build and run Next.js applications in production, including this site, so we tune against field data rather than lab scores. If your Core Web Vitals are failing, your caching has become a mystery, or a self-hosted deployment behaves differently from local builds, talk to the senior engineer who would do the work.