"Reduce unused JavaScript" on a Next.js site: how to tell if it's your code
PageSpeed flags unused JavaScript on most Next.js sites. How to find what each chunk contains, which fixes work, and when it is just React.
Run PageSpeed Insights on almost any Next.js App Router site and you'll see "Reduce unused JavaScript", usually with a few dozen KiB of "potential savings" attached. It reads like a to-do item. Often it isn't one: a large share of that number is React and the App Router's own runtime, which no amount of app-level tidying will remove.
Often isn't always, though. Sometimes the audit is pointing straight at a client component that's bigger than it needs to be, or at a third-party script. This post covers how to tell the two apart, the fixes that work when it is your code, and when to stop looking.
What the audit measures
Lighthouse loads the page, records which bytes of each JavaScript file actually execute during that load, and flags files where a large share didn't run. "Unused" here means "not executed during this one page load". It doesn't mean "never needed".
That distinction matters for a framework. React ships the code for state updates, transitions, error boundaries and much more. A mostly-static page won't exercise most of it on first load, so Lighthouse counts it as unused, even though the next click might need it.
Step 1: read which files it names
Expand the audit in PageSpeed Insights. It lists each file with its transfer size and the estimated unused bytes. On a Next.js site those files live under /_next/static/chunks/, with hashed names that don't tell you much.
Here's what it named on this site, which has a homepage and an MDX blog and very little interactivity:
| Chunk | What it turned out to be |
|---|---|
| ~70 KiB | React + ReactDOM, as bundled by Next |
| ~38 KiB | The App Router's client runtime |
The total flagged was about 47 KiB of unused JavaScript, all of it inside those two files. Everything I actually wrote that runs in the browser (navigation, the contact form, toasts, the theme toggle) compiles to a single chunk of about 27 KB raw, 8 KiB over the wire, and that chunk wasn't on the list.
Step 2: identify what's inside each chunk
Hashed filenames don't tell you much, so open them up. There are a few ways to do it.
The bundle analyzer. Recent versions of Next.js include an interactive analyzer for Turbopack builds:
npx next experimental-analyzeIt opens a web UI showing every module in every chunk, so you can match a flagged filename to its contents. If you're on webpack instead, @next/bundle-analyzer does the same job.
The Coverage panel in Chrome DevTools. Open DevTools, press Ctrl+Shift+P (Cmd+Shift+P on a Mac), run Show Coverage, then reload. You get the same "unused bytes" figure per file that Lighthouse uses, and clicking a file shows exactly which lines ran. This is the fastest way to see whether unused code is concentrated in your component or spread through the framework.
Just read it. Open the chunk URL in a tab and search for a name you recognise: one of your component names, a library you installed, react-dom. Minified code still keeps plenty of strings.
Step 3: rule out the usual culprits
Before writing the audit off as framework overhead, check these. Each one is a real, fixable problem that looks the same in the report.
A "use client" boundary set too high
In the App Router, everything a "use client" file imports becomes client JavaScript. Put the directive at the top of a page or layout to make one button interactive, and the whole tree under it ships to the browser.
The fix is to push the directive down to the smallest component that actually needs state or event handlers, and keep the rest as server components:
'use client'
export default function Pricing() {
const [annual, setAnnual] = useState(false)
return (
<section>
{/* lots of static markup */}
<BillingToggle annual={annual} onChange={setAnnual} />
{/* more static markup */}
</section>
)
}Moving the state into BillingToggle, and the "use client" with it, leaves Pricing as a server component that sends HTML, not JavaScript.
A heavy library in a client component
A date library, a charting package, a markdown parser or a syntax highlighter imported into a client component ships all of its weight to every visitor. Ask whether the work can happen on the server or at build time instead. On this blog, MDX compilation and code highlighting both run at build time, so the browser receives finished HTML with colour already applied and none of the tooling.
If the library really does need to run in the browser, load it only when it's needed:
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })Barrel imports
Importing one icon from a package that re-exports thousands can pull in more than you asked for, depending on how the package is built. Next.js has optimizePackageImports for this, and it already applies it automatically to a list of common libraries such as lucide-react. If a large package of yours isn't on that list, add it:
module.exports = {
experimental: {
optimizePackageImports: ['some-big-ui-library'],
},
}Third-party scripts
Analytics, chat widgets, tag managers and embeds are frequent entries in this audit, and they're the easiest to miss because they aren't in your bundle at all. Google's gtag.js alone is well over 100 KiB.
You can't shrink a third-party script, but you can control when it loads. This site loads gtag.js with next/script's lazyOnload strategy, which waits until the browser is idle after the page has loaded:
<Script
id="ga"
strategy="lazyOnload"
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
/>The common wrapper component I'd been using before loaded it earlier and preloaded it at high priority. On a throttled mobile run, that competed with the HTML and cost about 1.2 seconds of First Contentful Paint. Moving it to lazyOnload mattered far more for the score than anything in the unused-JavaScript audit.
A polyfill bundle
If you spot a large core-js or polyfill chunk in the build output, check how it's loaded before panicking. On this site it's emitted inside <script noModule>, which modern browsers skip entirely. You can confirm it in the built HTML, and by watching the Network panel in a current browser, where it never appears.
Step 4: know when to stop
If the flagged files are the React and App Router chunks, and you've ruled out everything above, you're done. There's no app-level change that removes React from a React site. The audit will keep showing up on every PageSpeed run, and it'll keep reading like a bug, but the right move is to stop re-profiling it.
Two related things you also don't control from the repo:
- Compression level. Vercel serves this site with brotli, but not at the maximum level. The React chunk arrives at 70.2 KiB, while compressing the same file locally at brotli's highest quality gives 58.9 KiB. That gap belongs to the host's settings, not to the code.
- Framework version. Each major Next.js and React release changes the size of the runtime. Upgrading can help, but it's an upgrade decision, not a performance fix.
The other things PageSpeed flagged on this site, and which of them turned out to be real, are in the SEO plumbing behind this blog.
Frequently asked questions
Why does PageSpeed say 'Reduce unused JavaScript' on my Next.js site?
Lighthouse flags any JavaScript that didn't execute during the test page load. On a Next.js App Router site, much of that is usually React and the router runtime, which ship with every page but aren't fully used on first load. Check which files the audit names before assuming it's your code.
Can I remove unused React code from a Next.js bundle?
Not in any practical way. React and the App Router runtime are what make the page work after it loads. What you can control is your own client code: where "use client" boundaries sit, which libraries run in the browser, and when third-party scripts load.
How do I see what's inside a Next.js chunk?
Run npx next experimental-analyze for a Turbopack build, or use @next/bundle-analyzer with webpack. Chrome DevTools' Coverage panel also shows used and unused bytes per file, line by line.
Does unused JavaScript hurt SEO?
Not directly. Google uses Core Web Vitals from real visitors, not Lighthouse audits. Unused JavaScript only matters if it slows those metrics down, usually by delaying first paint or blocking the main thread.
Should third-party scripts like Google Analytics use lazyOnload?
For analytics that doesn't need to fire before the page is interactive, lazyOnload is a good default. It keeps the script out of the critical path. The trade-off is that very short visits may not be recorded.