Next.js Performance: Using the Framework's Tools Well
This topic is part of Framework Performance. Next.js gives React applications server rendering, static generation, streaming, image and font optimisation, script loading strategies, route-level code splitting and, with the App Router, React Server Components. Used well, these produce pages with fast LCP, minimal client JavaScript and good INP. Used carelessly, a Next.js app can ship hundreds of kilobytes of JavaScript per page, hydrate the whole tree, lazy-load its LCP image, flash fallback fonts and run tag managers during hydration.
The most common Next.js performance problems come from a few patterns: "use client" directives placed high in the tree (turning large parts of the app into client components), large dependencies imported into shared layouts, next/image used without priority or sizes on the LCP image, third-party scripts loaded with the wrong strategy, and dynamic rendering (or uncached data fetching) where static or cached output would do. Each has a framework-native fix.
The Metric Degradation This Topic Addresses
Next.js apps typically struggle with INP and TBT when client JavaScript is large and hydration is heavy, with LCP when the hero image is misconfigured or content depends on client-side fetching, and with CLS when fonts swap without size-adjusted fallbacks or images lack dimensions. The build output's "First Load JS" column shows client JavaScript per route; values above 150–200KB compressed usually indicate room for improvement. In the field, segment metrics by route.
Prerequisites
- The build output (
next build) with First Load JS per route. @next/bundle-analyzerfor chunk contents.- Field Core Web Vitals per route (via
useReportWebVitalsor a RUM tool). - Knowledge of which routes are static, dynamic or ISR.
1. Environment Setup: Read the Build Output
next build prints each route with its rendering mode (static, SSG, dynamic) and First Load JS. Note routes with large First Load JS and dynamic routes that could be static.
// next.config.js — enable the bundle analyzer when ANALYZE=true.
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' });
module.exports = withBundleAnalyzer({ reactStrictMode: true });
// trade-off: the analyzer adds build time; run it on demand, not in every CI build.
2. Capture a Baseline
Record First Load JS per key route, LCP/INP/CLS per route in the field, and lab traces of hydration on a throttled profile.
3. Isolate the Causes
For large bundles, find heavy dependencies and "use client" boundaries. For slow LCP, check the LCP element: is it a next/image with priority? Does it depend on client data? For CLS, check fonts and images. For INP, profile interactions and hydration.
4. Apply Framework-Native Fixes
Push "use client" down to leaf components; keep layouts and content as Server Components. Load heavy client components with next/dynamic. Mark the LCP image with priority and correct sizes. Use next/font for fonts. Load third-party scripts with next/script strategies. Prefer static rendering and cached fetches; use partial prerendering for pages that mix static and dynamic content.
// Leaf-level client component: only the interactive part ships JavaScript.
// app/product/[id]/page.js (Server Component)
import AddToCart from './AddToCart'; // 'use client' inside AddToCart.js
export default async function Page({ params }) {
const product = await getProduct(params.id); // runs on the server
return (<article><h1>{product.name}</h1><p>{product.description}</p><AddToCart id={product.id} /></article>);
}
// trade-off: Server Components cannot use state or effects; split interactive
// parts into small client components and pass serialisable props.
Deconstructing Next.js Rendering Modes
Static rendering produces HTML at build time (or on first request with caching); it is the fastest and should be the default for content pages. Dynamic rendering runs per request, triggered by uncached data fetches or request-specific APIs (cookies, headers, search params). Incremental Static Regeneration (revalidation) regenerates static pages in the background at intervals or on demand. Partial prerendering combines a static shell, served instantly, with dynamic holes streamed in via Suspense — ideal for pages that are mostly static with a few personalised parts.
A page becomes dynamic as soon as any part of it uses request-time data, which can silently turn a cacheable page into one rendered on every request. Check the build output after changes, and isolate dynamic parts with Suspense boundaries (and partial prerendering) rather than letting them make the whole route dynamic.
Advanced Diagnostics and Edge Cases
Client boundaries pulling in dependencies. A "use client" file imports everything it uses into the client bundle, including large libraries used only for formatting.
Barrel files. Importing from index files that re-export many modules can pull more code than needed; optimizePackageImports helps for some libraries.
Middleware cost. Middleware runs on every matched request; heavy logic there adds to TTFB.
Image optimisation cold misses. The image optimiser transforms on first request; cache results and use appropriate minimumCacheTTL or an external loader with a CDN.
Prefetching. Link prefetches routes in the viewport in production, which can generate many requests on link-dense pages; adjust prefetch per link where needed.
Validation and Budgeting
Track First Load JS per route in CI (fail on growth beyond a threshold), LCP and INP per route in the field, and the static/dynamic status of key routes from build output.
| Budget | Target |
|---|---|
| First Load JS (key routes, compressed) | < 130 KB |
| LCP image | priority + correct sizes |
| Fonts | next/font with fallback adjustment |
| Static routes | Content pages static or ISR |
Worked Example: A Media Company's Article Pages
A media company's Next.js article pages shipped 260KB of First Load JS because the root layout was a client component (it used a theme hook) and imported a large analytics SDK and a comment widget. The hero image was a next/image without priority, and Google Fonts were loaded via a stylesheet link. LCP p75 was 3.1 seconds and INP p75 270ms. The team moved the theme toggle into a small client component, made the layout a Server Component, loaded the comments widget with next/dynamic when scrolled into view, moved the analytics SDK to next/script with afterInteractive, added priority and sizes="100vw" to the hero, and switched to next/font. First Load JS fell to 92KB, LCP p75 to 2.0 seconds, INP p75 to 150ms, and CLS from fonts disappeared.
Caching and Data Fetching
Next.js caches fetch results and rendered output in several layers, and defaults have changed across versions. For performance, the important questions are: is this route's HTML cached and served statically or regenerated on demand, and are slow data sources cached on the server? Explicit revalidate settings, cache options on fetch, and tagging for on-demand revalidation let you keep most pages static while updating them when content changes. Uncached, per-request fetches of slow APIs are a common cause of slow TTFB in App Router apps.
Third-Party Scripts and Next.js
next/script offers strategies: beforeInteractive (critical scripts injected early), afterInteractive (default; loads after hydration begins), lazyOnload (during idle time) and worker (experimental, via Partytown). Most analytics and tag managers belong in afterInteractive or lazyOnload; chat widgets and social embeds in lazyOnload or loaded on interaction. Scripts in beforeInteractive should be rare, because they compete with hydration and the LCP.
Server Components in Practice
The App Router makes every component a Server Component unless it, or a module that imports it, declares "use client". Server Components can fetch data directly, use server-only libraries and render content without adding to the client bundle. The performance discipline is to keep the client boundary at the leaves: a page renders mostly server components, and small client components handle interactions — a quantity picker, a favourite button, a filter panel. Data flows from the server as props, which must be serialisable.
Two patterns keep boundaries small. First, pass server-rendered content into client components as children rather than importing it inside them; the content then stays server-rendered even inside an interactive wrapper (for example, an accordion that expands server-rendered text). Second, split interactive behaviour out of shared UI components: a Card that renders content on the server and a CardActions client component for its buttons, rather than one client Card.
Navigation Performance
After the first load, Next.js navigations are client-side: the router fetches the next route's React Server Component payload and code, then renders it. Link prefetches routes as links enter the viewport in production — the static parts fully, dynamic routes partially down to the nearest loading boundary — so most navigations feel instant. Two things slow them down: large route payloads (lots of data serialised into the RSC payload) and dynamic routes without loading.js or Suspense boundaries, which wait for the server before showing anything. Keep payloads lean by passing only the props client components need, and add loading states so the shell of the next route appears immediately.
Monitoring Next.js Apps in the Field
useReportWebVitals (or the web-vitals library directly) reports LCP, INP, CLS, FCP and TTFB for the initial load; Next.js also reports custom metrics for hydration and route changes in some versions. Send these to your analytics with the route pattern (not the full URL) so you can aggregate by template. Field data should be the final judge: a route whose First Load JS dropped by half but whose INP did not move probably has a different bottleneck — a slow interaction handler, a third-party script or a large DOM — that deserves attention next.
Middleware and Edge Runtime
Middleware runs before every matched request, at the edge in most deployments. It is ideal for cheap decisions — redirects, rewrites, setting a cookie for an experiment, choosing a locale — and harmful when it does slow work: fetching user data from a distant database, calling third-party APIs, or parsing large tokens on every request. Because middleware sits in front of static and cached pages too, a 100ms middleware adds 100ms to TTFB for every page, including those that would otherwise be served instantly from the edge. Keep middleware fast, scope its matcher to the routes that need it, and move expensive logic into the routes themselves where it can be cached or streamed.
Edge-runtime route handlers and pages start quickly but have limited APIs, and if they fetch data from a single-region database, each query pays the full round trip. Prefer the Node.js runtime near your data for data-heavy routes, and the edge for logic that can run with cached or local data.
A Rollout Plan
- Read build output and bundle analysis; rank routes by First Load JS and traffic.
- Push
"use client"boundaries down; keep layouts and content as Server Components. - Defer heavy client components with
next/dynamic. - Fix the LCP image (
priority,sizes) and fonts (next/font). - Move third-party scripts to appropriate
next/scriptstrategies. - Make content routes static or ISR; use partial prerendering for mixed pages.
- Add First Load JS budgets and per-route field monitoring.
Common Pitfalls
"use client"in layouts. Turns the whole subtree into client components.- LCP image without priority. Lazy-loaded and discovered late.
- Fonts via external stylesheets. Render-blocking and CLS-prone; use
next/font. - Accidental dynamic rendering. One
cookies()call makes a whole route dynamic. - Tag managers in beforeInteractive. Compete with hydration and LCP.
FAQ
What is a good First Load JS size?
For content routes, under about 100–130KB compressed is a good target. App-like routes may need more, but every 100KB costs hundreds of milliseconds on mid-range phones.
Does the App Router improve performance?
It enables Server Components, streaming and partial prerendering, which can greatly reduce client JavaScript. The gains depend on keeping client boundaries small.
Should every image use next/image?
It is a good default. Make sure the LCP image has priority and that sizes matches the layout, or images will be oversized.
Why is my page dynamic?
It uses request-time data (cookies, headers, search params) or uncached fetches. The build output shows which routes are dynamic.
How do I measure Web Vitals in Next.js?
Use the useReportWebVitals hook to send metrics to your analytics, or a RUM tool. Segment by route.
Does Link prefetching hurt performance?
It speeds up navigations but adds requests. On link-dense pages, disable prefetch for low-value links.
Is the Pages Router slow?
Not inherently, but every page's components hydrate on the client. The App Router allows shipping less JavaScript through Server Components.
How do I find accidental client components?
Search for "use client" directives and check what they import: any shared module imported from a client file becomes client code. The bundle analyzer shows which modules ended up in client chunks.
Does Next.js cache everything by default?
Caching defaults have changed between versions. Check the documentation for your version, and set caching and revalidation explicitly for data and routes where performance matters.
Is Vercel required for good Next.js performance?
No. Self-hosted and other platforms can serve Next.js well, provided static output and images are cached at a CDN and streaming is not buffered by proxies.
What is the biggest single Next.js win?
For content-heavy apps, moving client boundaries to the leaves so most of the page is Server Components. For image-heavy pages, fixing the LCP image’s priority and sizes.
Should I upgrade Next.js for performance?
Often yes — releases regularly improve bundling, caching and rendering. Compare build output and lab traces before and after upgrading, since defaults sometimes change.
How do Server Actions affect performance?
They remove the need for client-side API code and keep logic on the server. Use transitions to show pending state while they run so interactions stay responsive.
Guides in This Topic
- Reducing Next.js first load JavaScript — smaller client bundles.
- Eliminating font CLS with next/font — self-hosted fonts without shifts.
- Next.js partial prerendering — static shells with dynamic holes.
- Choosing next/script loading strategies — third-party scripts without blocking.
- Fixing slow LCP with the Next.js Image component — priority, sizes and loaders.
Related
- React rendering performance — rendering costs inside Next.js apps.
- Streaming SSR with React Suspense — the streaming model Next.js builds on.
- JavaScript bundle optimization — general bundle techniques.