·
---
name: nextjs-app-router-bundle-auditor
description: Use this skill when you need to audit a Next.js App Router application for unnecessary client components, oversized client-side dependencies, hydration overhead, route-specific JavaScript bloat, or avoidable code shipped to the browser. Trigger it when reviewing PRs that add 'use client', providers, wrappers, search/nav shells, charts, markdown/rendering libraries, rich text viewers, syntax highlighters, dynamic imports, or new third-party packages in app/ code. Also use it when a route feels slow after migration to the App Router, when bundle analysis shows unexpectedly large client chunks, when Lighthouse or field metrics suggest too much JavaScript, or when someone proposes lazy loading or serverExternalPackages as a fix without tracing why the code entered the client graph. Be aggressive about using this skill: App Router performance regressions are often caused by a client boundary placed too high in the tree, and the right first question is usually why code is in the client graph at all.
---
Next.js App Router Bundle Auditor
In the App Router, pages and layouts are Server Components by default, so they do not add code to the browser bundle unless they cross a client boundary.[2][5]
'use client' creates a bundle boundary: once a file is a Client Component, its imports and client-side descendants join the client module graph.[2][6]
Most App Router bundle regressions come from a boundary placed too high in the tree or around code that imports heavy helpers, providers, renderers, or third-party packages.[4][2][6]
Audit the route that is shipping JavaScript, not the repository in the abstract. Package lists and generic optimization advice are weaker than route-aware analysis of the actual client graph.[3][4][5]
Measure before editing code. Run pnpm next experimental-analyze on Turbopack projects, or use @next/bundle-analyzer with ANALYZE=true pnpm build on Webpack projects.[3]
Save diffable artifacts when you expect to compare before and after states. Use pnpm next experimental-analyze --output and keep the generated .next/diagnostics/analyze directory.[3]
Pick one heavy or suspicious route at a time. Filter the analyzer to that route and inspect client modules first.[3]
For every large client module, trace the import chain upward until you find the nearest avoidable client boundary. The first question is why this code is in the client graph, not how to split it later.[3][2][4]
Treat layouts, providers, nav shells, search wrappers, modal shells, MDX renderers, chart wrappers, syntax highlighters, rich text viewers, and helper-heavy convenience components as primary suspects.[2][3][14][10]
Move non-interactive rendering and data transformation back to the server. If code only turns data into markup and does not need browser APIs or immediate client interaction, keep it in a Server Component.[3][13]
Keep client boundaries at the leaves. If a root layout became client-only to host a theme provider, auth provider, nav state, or search UI, split those concerns into smaller client files and leave the outer shell on the server.[4][10][11][12][2]
If state can live in the URL, prefer URL state or navigation over client React state. That often removes the need for a client boundary altogether.[4]
Use lazy loading only for genuinely optional client islands or libraries, then verify the result empirically. Do not assume dynamic() fixed the problem.[7][8][9]
Re-run the same route analysis after each change and compare import chains, not just scores. A route-level before/after diff is stronger evidence than a noisy Lighthouse swing.[3]
# Turbopack analyzer
pnpm next experimental-analyze
pnpm next experimental-analyze --output
cp -r .next/diagnostics/analyze ./analyze-before-refactor
# Webpack fallback
pnpm add @next/bundle-analyzer
ANALYZE=true pnpm build
If the analyzer points to a large package under a client route, do not start by debating the package. First locate the 'use client' file or client-only import chain that pulled it into the browser graph.[3][2][4]
If a dependency only transforms data into HTML or SVG, render the result on the server and send markup instead of the library. Markdown parsing, syntax highlighting, and chart rendering are common cases.[3][13]
If a provider forced a layout or page to become client-only, isolate that provider in its own Client Component and wrap only the subtree that needs it.[14][10][11][12]
If a component only needs the browser after user interaction, defer that island. If it needs the browser during initial render, keep the client surface as small and deterministic as possible.[18][2][4]
Audit hydration cost, not just transferred bytes. Server Components reduce browser JavaScript because their code is not shipped and React does not hydrate them.[2][4]
Use useReportWebVitals for production measurement only if you keep the instrumentation island tiny. Treat webVitalsAttribution as optional deep diagnosis because the current Next.js docs mark it experimental and not recommended for production.[16][17]
Do not let serverExternalPackages distract the audit. It is a server bundling escape hatch for Node-specific compatibility, not a primary way to reduce browser JavaScript.[15][2]
Use this pattern when analyzer output shows layout-level client chunks or when a PR made app/layout.tsx client-only just to host a provider, search box, or nav state.[2][4][10]
// Before
// app/layout.tsx
'use client'
import Logo from './logo'
import Search from './search'
import ThemeProvider from './theme-provider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
<nav>
<Logo />
<Search />
</nav>
{children}
</ThemeProvider>
</body>
</html>
)
}
// After
// app/layout.tsx
import Logo from './logo'
import Search from './search'
import ThemeProvider from './theme-provider'
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>
<nav>
<Logo />
<Search />
</nav>
{children}
</ThemeProvider>
</body>
</html>
)
}
// app/search.tsx
'use client'
export default function Search() {
return <input />
}
// app/theme-provider.tsx
'use client'
export default function ThemeProvider({ children }) {
return children
}
After the refactor, re-run the route analysis and confirm that the import chain no longer roots large client chunks in the layout shell.[3]
If the state driving that UI only belongs in the URL, remove the client state entirely instead of preserving it behind a thinner client wrapper.[4]
Use this pattern when a Client Component imports libraries that mostly turn source data into markup, such as markdown parsers, syntax highlighters, chart renderers, or rich text serializers.[3]
// Before
'use client'
import { marked } from 'marked'
import sanitizeHtml from 'sanitize-html'
export default function PostBody({ markdown }) {
const html = sanitizeHtml(marked.parse(markdown))
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
// After
import { marked } from 'marked'
import sanitizeHtml from 'sanitize-html'
export default function PostBody({ markdown }) {
const html = sanitizeHtml(marked.parse(markdown))
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
React's Server Components docs show this exact class of win: rendering markdown on the server removes heavy parsing libraries from the client bundle.[13]
Dagster applied the same idea at a larger scale. Their dbt docs rewrite moved core rendering to React Server Components, read large JSON files on the server with fs/promises, and cut page load time from over 4.5 seconds to under 220 milliseconds while reducing memory from 350 MB to 16 MB.[19]
Carry over Dagster's implementation warning too: do not assume import() is the right way to load giant JSON files. Their team found that switching from JSON import() to fs/promises plus JSON.parse() removed major performance issues.[19]
Do not approve a change just because it contains dynamic(). Next.js explicitly says that when a Server Component dynamically imports a Client Component, automatic code splitting is currently not supported.[7]
Confirmed issue #61066 shows that a Client Component dynamically imported by a Server Component can still appear in the initial client bundle even when it is never rendered.[8]
Client-wrapper dynamic imports can shrink the initial bundle while harming SSR behavior and navigation stability. Issue #66414 reports large layout shifts after moving dynamic imports behind an intermediary client file.[9]
Do not use ssr: false in a Server Component. Next.js documents that this option only works for Client Components and errors in Server Components.[7]
Treat hydration mismatches as bugs, not performance techniques. React warns that typeof window !== 'undefined' checks in render, browser-only APIs during render, and two-pass client flips can slow hydration and create jarring post-hydration changes.[18]
Do not audit dependencies from package.json alone. Audit what is imported by Client Components, because those imports are what the browser downloads, parses, and executes.[4][3]
If a package exports hundreds of modules, check whether optimizePackageImports applies before accepting broad client imports from icon or utility libraries.[3]
If a third-party package behaves strangely across server and client boundaries, inspect whether its published entry points preserve the 'use client' directive. Next.js warns that some bundlers strip it and points to real source-repo configurations that preserve it.[20][21][22]
Made with Webhound · Ask questions about this research, build on it, or start your own
25 sources · $15 spent · Ask Webhound about this research, build on it, or start your own
Start free