To optimize a Next.js app, measure Core Web Vitals from real users, ship less client JavaScript by keeping most of the page as Server Components, make the main image and fonts load early, delay third-party scripts, and cache work that does not change on every request. Next.js 16 adds new tools for each step: Turbopack by default, a built-in bundle analyzer, Cache Components with "use cache", stable React Compiler support, and new next/image defaults.
This guide walks through each of those in order of impact, with code that matches the current Next.js 16 APIs. Along the way it shows how we handle each one on ui.wensity.com, which is a large Next.js 16 App Router site with hundreds of pages, live component previews, MDX docs and ads, so the advice comes from a real production codebase rather than a demo.
Measure LCP, INP and CLS from real users. Run the bundle analyzer before changing code. Push "use client" down to the smallest interactive pieces. Load the hero image eagerly and the rest lazily. Use next/font, delay third-party scripts, cache stable work with "use cache", and stream slow sections with Suspense.
What changed for performance in Next.js 16
If you are upgrading or reading older tutorials, these are the changes that matter for speed:
| Change in Next.js 16 | What it means for you |
|---|---|
Turbopack is the default for next dev and next build | Faster builds and refreshes with no flags. Opt out with --webpack only if a plugin needs it. |
| Built-in bundle analyzer (16.1 and later) | next experimental-analyze shows what every route ships, with import chains. |
Cache Components with cacheComponents: true | Replaces the old experimental Partial Prerendering flag. Static shell plus streamed dynamic parts. |
cacheLife and cacheTag are stable | No more unstable_ prefix. revalidateTag now takes a cache profile as a second argument. |
New updateTag for Server Actions | Expires and refreshes cached data in the same request, so users see their own changes at once. |
| React Compiler support is stable | reactCompiler: true is a top-level option, but it is still off by default. |
next/image changes | priority is deprecated in favor of preload, qualities defaults to [75], and optimized images cache for 4 hours by default. |
middleware is renamed to proxy | Rename middleware.ts to proxy.ts. The proxy runs on the Node.js runtime. |
Measure Core Web Vitals first
Core Web Vitals are Google's three measures of real user experience, and they are what both your users and Google's ranking systems feel. Lighthouse is useful for debugging, but the score that counts comes from real visits.
| Metric | What it measures | Good |
|---|---|---|
| Largest Contentful Paint (LCP) | How fast the main content appears | 2.5 seconds or less |
| Interaction to Next Paint (INP) | How fast the page responds to clicks, taps and typing | 200 milliseconds or less |
| Cumulative Layout Shift (CLS) | How much the layout jumps while loading | 0.1 or less |
INP replaced First Input Delay in 2024, so advice that still talks about FID is out of date. To see your own numbers, check the Core Web Vitals report in Google Search Console and PageSpeed Insights, and collect them yourself with the useReportWebVitals hook:
// app/_components/web-vitals.tsx"use client";import { useReportWebVitals } from "next/web-vitals";const report = (metric: { name: string; value: number }) => {navigator.sendBeacon?.("/api/vitals", JSON.stringify(metric));};export function WebVitals() {useReportWebVitals(report);return null;}
Render <WebVitals /> once in your root layout. Keep the callback defined outside the component so its reference never changes, otherwise metrics can be reported twice.
Find what is heavy with the Next.js bundle analyzer
Before you delete code or swap libraries, look at what each route actually sends to the browser. Slow Next.js apps are rarely slow because of one bad component. Usually a few dependencies quietly ended up in the client bundle.
On Next.js 16.1 and later, the built-in analyzer works with Turbopack and does not need a build:
npx next experimental-analyze
It opens an interactive view where you can filter by route and by client or server code, and trace exactly which import pulled a large module into a page. If your project still builds with webpack, the @next/bundle-analyzer plugin does the same job.
The questions to ask are simple: is a chart, editor or date library loading on a page that does not use it? Did a server-only helper leak into the client? Did one "use client" file drag a whole page into the browser?
How we do it. On ui.wensity.com the webpack analyzer only loads when ANALYZE=true is set, so it never touches normal builds. CI also runs a bundle budget script after every build that fails if the heaviest route's first-load JavaScript, or any generated data file, grows past a set limit. A budget catches regressions in review instead of in production.
Ship less JavaScript with React Server Components
In the App Router, every component is a React Server Component unless you add "use client". Server Components render on the server and send HTML with no JavaScript for that part of the page. The most common way to slow a Next.js app down is putting "use client" too high in the tree, because everything imported below that line becomes client JavaScript that has to download and hydrate.
Keep layouts, article bodies, product data, pricing tables and SEO content on the server. Put "use client" only on the small interactive pieces: toggles, menus, forms, charts and anything that needs window or browser storage.
// app/pricing/page.tsx stays a Server Componentimport { PricingTable } from "./pricing-table";import { BillingToggle } from "./billing-toggle";export default async function PricingPage() {const plans = await getPlans();return (<main><h1>Pricing</h1><PricingTable plans={plans} /><BillingToggle /></main>);}
// billing-toggle.tsx is the only client code on the page"use client";import { useState } from "react";export function BillingToggle() {const [yearly, setYearly] = useState(true);return (<button type="button" onClick={() => setYearly(!yearly)}>{yearly ? "Billed yearly" : "Billed monthly"}</button>);}
When a Client Component needs to wrap server content, pass that content as children. It still renders on the server and never becomes client JavaScript.
Fix barrel imports with optimizePackageImports
Icon sets and UI kits often export hundreds of modules from one entry file. experimental.optimizePackageImports rewrites those imports at build time so only the modules you use are loaded:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = {experimental: {optimizePackageImports: ["@tabler/icons-react", "recharts"],},};export default nextConfig;
Next.js already does this automatically for many popular packages, including lucide-react, date-fns, lodash-es and antd, so only add packages the analyzer shows are expensive. Our shared config adds @tabler/icons-react, framer-motion, several Radix packages and our own UI package.
Lazy load heavy components with next/dynamic
Some client components are large but not needed right away: a code editor, a chart, a modal, a 3D scene. next/dynamic splits them into their own chunk that loads only when the component renders:
import dynamic from "next/dynamic";const Chart = dynamic(() => import("./chart"), {loading: () => <div className="h-64" />,});
Give the loading state the same size as the real component so nothing jumps when it arrives, which protects CLS.
How we do it. ui.wensity.com has more than 200 live component and block previews. A build script generates one next/dynamic export per preview, so a component page only downloads the preview it actually shows. On the /components index, the cards are WebP screenshots of each preview rather than dozens of live React components, which keeps that page light no matter how many components we add.
Optimize images with next/image
The next/image component resizes images, serves modern formats like WebP and AVIF, lazy loads anything below the fold, and reserves space so the layout does not shift. Two rules cover most of the gains: always give images real width and height (or fill with a sized parent), and give a full-width image an accurate sizes value so phones do not download desktop-sized files.
The image that is your LCP element, usually the hero or article cover, should start loading immediately instead of lazily:
import Image from "next/image";export function Hero() {return (<Imagesrc="/hero.webp"alt="Product dashboard"width={1600}height={900}sizes="(max-width: 768px) 100vw, 1200px"loading="eager"fetchPriority="high"/>);}
In Next.js 16, the old priority prop is deprecated. Its replacement is preload, which adds a preload link to the page head, but the Next.js docs recommend loading="eager" or fetchPriority="high" in most cases, and preload only when you need the image to start loading before the browser finds it in the page.
Next.js 16 also changed image defaults you should know about:
- Quality.
images.qualitiesnow defaults to[75]. If you pass anotherqualityvalue, add it to the list innext.config. - Caching. Optimized images are cached for at least 4 hours by default, up from 60 seconds. There is no way to purge that cache, so version image URLs when a file changes.
- Query strings. Local images with a query string, like
/photo.webp?v=2, are blocked unless you allow them withimages.localPatterns.
How we do it. We version every generated thumbnail and social image with a ?v= content hash so browsers and CDNs never serve a stale copy. Next.js 16 blocked those URLs until we allowed them in images.localPatterns, a breaking change worth checking on any upgrade.
Load fonts with next/font
Web fonts are a common cause of slow text and layout shift. next/font downloads fonts at build time, serves them from your own domain, and preloads them, so there is no request to Google Fonts and no flash of invisible text. For your own font files, use next/font/local:
import localFont from "next/font/local";export const satoshi = localFont({src: [{ path: "./fonts/Satoshi-Variable.woff2", weight: "300 900", style: "normal" }],variable: "--font-satoshi",display: "swap",preload: true,fallback: ["ui-sans-serif", "system-ui", "sans-serif"],});
This is the setup ui.wensity.com uses for its body font. A single variable font file covers every weight from 300 to 900, display: "swap" shows fallback text immediately, and the fallback list keeps that first paint close to the final font. Load as few families and files as you can, and prefer variable fonts over many static weights.
Delay third-party scripts with next/script
Analytics, chat widgets, ads and embeds are often the biggest drag on INP, because their JavaScript competes with your page for the main thread. The next/script component controls when they load:
beforeInteractiveloads before the page becomes interactive. Use it only for scripts that must run first, like a consent manager.afterInteractiveis the default and loads right after the page becomes interactive. Good for analytics.lazyOnloadwaits until the browser is idle. Use it for chat widgets, social embeds and anything below the fold.workeris experimental and moves a script to a web worker.
import Script from "next/script";<Script src="https://example.com/chat-widget.js" strategy="lazyOnload" />;
How we do it. Our ad script is not in the initial HTML at all. A small client component injects it 500 milliseconds after the first load, so the page paints and becomes interactive before the ad starts loading.
Cache stable work with Cache Components
Next.js 16 replaces the old caching flags with Cache Components. Turn it on in next.config with cacheComponents: true, and mark functions or components whose results can be reused with the "use cache" directive:
import { cacheLife, cacheTag } from "next/cache";export async function getPosts() {"use cache";cacheLife("hours");cacheTag("posts");return db.posts.findMany();}
With Cache Components on, a route is prerendered into a static shell, and anything that must be fresh on every request goes inside a <Suspense> boundary so it streams in after the shell. This is how Partial Prerendering works in Next.js 16.
cacheLife takes a built-in profile:
| Profile | Use it for | Revalidates every | Expires after |
|---|---|---|---|
seconds | Real-time data | 1 second | 1 minute |
minutes | Frequently updated content | 1 minute | 1 hour |
hours | Content updated several times a day | 1 hour | 1 day |
days | Content updated daily | 1 day | 1 week |
weeks | Content updated weekly | 1 week | 30 days |
max | Stable content that rarely changes | 30 days | 1 year |
To update cached data, there are now two tools. revalidateTag("posts", "max") marks the data stale, and readers keep seeing the old version while a fresh one is fetched in the background, which is fine for blog posts and catalogs. updateTag("posts"), used inside a Server Action, expires the data and refreshes it in the same request, so the person who made the change sees it straight away.
How we do it. Our site-wide settings, like the list of profiles shown in the sidebar, are cached under a tag with no time-based expiry. Saving the settings form calls updateTag, so the change appears immediately, and nothing else ever hits the database for it.
Static rendering, ISR and streaming
The fastest page is one that is already built. Pages that are the same for every visitor should be prerendered at build time and served from the CDN.
Incremental Static Regeneration (ISR) keeps a page static but rebuilds it in the background on a schedule. Set export const revalidate = 3600 in a page to rebuild it at most once an hour, or set it to false to keep it static until the next deploy.
Streaming sends the static parts of a page right away and fills in slow parts as they finish. Wrap the slow part in <Suspense> with a fallback the same size as the content:
import { Suspense } from "react";export default function Dashboard() {return (<main><h1>Overview</h1><Suspense fallback={<div className="h-48" />}><RevenueChart /></Suspense></main>);}
Avoid data waterfalls too. When a page needs several independent pieces of data, start them together with Promise.all instead of awaiting them one after another.
How we do it. Every blog post on this site is prerendered at build time with revalidate = false, so a post is plain HTML on the CDN and only changes when we deploy.
Turn on the React Compiler
The React Compiler automatically memoizes components and values, so React skips work that has not changed, without you writing useMemo, useCallback or React.memo. It helps most on interactive pages with lots of state, where it can lower INP.
Next.js 16 made support stable, but it is still off by default. Install the Babel plugin and turn it on:
npm install -D babel-plugin-react-compiler
// next.config.tsconst nextConfig: NextConfig = {reactCompiler: true,};
Next.js only runs the compiler on files that contain JSX or hooks, so builds slow down only slightly. On a large codebase, start with the opt-in annotation mode, test the pages that matter, and widen from there.
Next.js SEO and performance go together
Speed is part of SEO, but Next.js gives you more than speed for search:
- Metadata API. Export
metadataorgenerateMetadatafrom each page for titles, descriptions and canonical URLs, all rendered on the server. - Sitemap with real dates.
app/sitemap.tsgenerates your sitemap. Give every URL an accuratelastModifieddate so search engines know what changed. Ours comes from each file's git history, so editing a post updates its date automatically. - Social images. Open Graph images can be generated per page. We render ours ahead of time into static files, so there is no image generation on each request.
- Structured data. Add JSON-LD for articles, products and FAQs inside the page, rendered on the server so crawlers see it immediately.
For the full component side of a fast site, our guide to the best React UI component libraries compares how much client code each one ships, and Framer Motion vs CSS animations covers keeping animation cheap.
Next.js performance checklist
- Core Web Vitals measured from real users, with LCP under 2.5 seconds, INP under 200 milliseconds and CLS under 0.1.
- Bundle analyzer run on the routes you changed, and a bundle budget in CI.
"use client"only on small interactive components.- Heavy client components split with
next/dynamic. - The LCP image loaded eagerly with
fetchPriority="high", everything else lazy, with correctsizes. - Fonts loaded with
next/font, using as few files as possible. - Third-party scripts on
lazyOnloadorafterInteractive, never blocking the first paint. - Stable data cached with
"use cache", and slow sections streamed withSuspense. - Static pages prerendered, with ISR for content that changes on a schedule.
- React Compiler tested on interactive pages.
Frequently asked questions
The bottom line
Next.js 16 already makes a lot of good decisions for you: Turbopack builds, automatic code splitting, image and font optimization. Most remaining speed comes from a few habits: measure real Core Web Vitals, keep client JavaScript small and close to where it is needed, load the main image early and everything else late, keep third-party scripts out of the way, cache stable work and stream the rest. Start with the bundle analyzer and the LCP element, fix what the numbers point to, and add a budget so the gains stay in place.

Parth Sharma
Founder, Wensity
Full stack developer and founder of Wensity. Obsessed with pixel perfect, high performance web experiences that feel alive.
Enjoyed this article?
Related articles

How to Recreate Apple's Scroll Animations in React
How Apple's scroll-driven product pages work, and how to build the same scroll animation in React with an image sequence on canvas, GSAP ScrollTrigger or a free ready-made component.
Read article
Framer Motion vs CSS Animations in React: Which Should You Use in 2026?
When to use CSS animations and when to use Framer Motion (now Motion) in React, covering scroll animations, View Transitions, exit and layout animations, performance, Tailwind CSS and GSAP.
Read article
