How to shrink a Next.js 16 build and Vercel deploy
Aden's web app is one Next.js 16 project with about 580 pages, built with Turbopack and deployed to Vercel. After the upgrade to Next 16.4, a prebuilt deploy's output was about 500 MB, and the biggest page's server function carried a 92 MB manifest. The steps below took the client reference manifests from 2,209 MB to 268 MB (419 MB to 6 MB gzipped), and the browser JavaScript of a signed-in page from 6.4 MB to 1.6 MB gzipped.
Each step starts with a measurement. Two of our first guesses were wrong by a factor of ten or more, and only the numbers showed it.
Use this guide as an agent skill
This page is also an agent skill. Save it in your project, and your coding agent loads it when you ask it to make a Next.js build or deploy smaller:
mkdir -p .claude/skills/nextjs-deploy-size
curl -fsSL https://aden.space/guides/how-to-shrink-a-nextjs-deploy.md \
-o .claude/skills/nextjs-deploy-size/SKILL.md
That path is Claude Code's. Other agents that read SKILL.md files take the same file in their own skills folder.
1. Measure the client reference manifests
Every page's server function ships a page_client-reference-manifest.js. For each client component the page's server tree renders, it lists the browser chunks and the server-rendering chunks that component needs. Its size is roughly the number of client components times the number of chunks in each list, and the function evaluates the whole file on every cold start.
Run this from the app folder after next build:
// manifests.mjs: every client reference manifest, raw and gzipped, largest first.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { gzipSync } from 'node:zlib'
const walk = (dir) =>
readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
return statSync(path).isDirectory() ? walk(path) : [path]
})
const manifests = walk('.next/server/app')
.filter((path) => path.endsWith('_client-reference-manifest.js'))
.map((path) => {
const source = readFileSync(path)
return { path, raw: source.length, gz: gzipSync(source).length }
})
.sort((a, b) => b.raw - a.raw)
const mb = (bytes) => `${(bytes / 1e6).toFixed(1)} MB`
const sum = (key) => manifests.reduce((total, m) => total + m[key], 0)
console.log(`${manifests.length} manifests: ${mb(sum('raw'))}, ${mb(sum('gz'))} gzipped`)
for (const m of manifests.slice(0, 10)) console.log(mb(m.raw).padStart(9), m.path)
On our build it printed 2,209 MB across 486 manifests, with single pages at 17 to 92 MB. A healthy page manifest is a few hundred KB to a few MB.
For the browser side, next build writes .next/diagnostics/route-bundle-stats.json, with every route's first-load chunks and their size.
2. Raise the server chunk size
If one page's manifest lists hundreds of server chunks per client component, the server chunks are too small. In Next 16.4, Turbopack's 4 KB minimum split one of our pages' server-rendering code (17 MB) into 981 chunks with a median of 5 KB (vercel/next.js#100260). Next 16.5 (from canary.31) lets you set the server thresholds:
// next.config.ts
experimental: {
turbopackChunking: {
minServerChunkSize: 40_000,
maxMergeServerChunkSize: 100_000,
},
},
Measure each value you try against two numbers: total manifest size, and the median size of a function in .vercel/output/functions. Bigger chunks shorten the lists, but past a point Turbopack copies shared code into more chunks. At a 100 KB minimum our median function grew from 74 MB to 130 MB. At 40 KB it stayed at 74 MB, and the manifests dropped from 419 MB to 9 MB gzipped.
Leave generateComponentChunks off. It writes every browser chunk in every manifest as a [chunk, parts, sizes] tuple, once per client component, which made up about half of each manifest's browser lists.
3. Render one client component per shell
Each client component a server component renders is its own entry in every page manifest under it. A server layout that wraps the page in 17 providers, each from its own 'use client' file, writes 17 entries, each repeating its chunk lists. Move them into one client module and render that:
// app-shell-client.tsx
'use client'
export const AppShellProviders = ({ flags, children }: { flags: Flags; children: ReactNode }) => (
<UploadProvider>
<FeatureFlagsProvider flags={flags}>
<HotkeysProvider>{children}</HotkeysProvider>
</FeatureFlagsProvider>
</UploadProvider>
)
The server layout still loads the data and passes it down as props. Do the same for the dialogs and overlays the shell mounts.
4. Load lookup tables of pages lazily
Check every component that picks one of many pages' components from a table, such as a loading state that reads the URL and renders that page's skeleton. Static imports in the table put every page's code into the chunk group of every route that renders it. Turbopack builds one browser chunk group per layout segment, so a table in the root layout reaches every page.
Make each entry a lazy import behind a Suspense boundary:
'use client'
import { lazy, Suspense } from 'react'
const TracksLoading = lazy(() => import('./tracks/loading'))
const InboxLoading = lazy(() => import('./inbox/loading'))
const SHAPES = {
'/app/tracks': <TracksLoading />,
'/app/inbox': <InboxLoading />,
}
export const RouteSkeleton = ({ pattern }: { pattern: keyof typeof SHAPES }) => (
<Suspense fallback={<LoadingMark />}>{SHAPES[pattern]}</Suspense>
)
This took our signed-in layout's browser chunk group from 279 chunks (6.4 MB gzipped) to 85 chunks (1.6 MB gzipped).
To keep skeletons instant on later navigations, start every entry's import once the browser is idle (requestIdleCallback). The next navigation then finds its skeleton in the module cache. For us that cost 103 requests and 0.5 MB gzipped per page load after every deploy, and it was worth it.
A layout that imports another section's layout imports its client components too. Our app layout imported the organisation layout to render organisation pages, and so loaded the organisation's loading states on every page. Lazy-load the parts only the other section paints.
5. Measure what a lazy import would free, not what a module pulls in
A module's reach (everything it imports, directly or not) overlaps with everything else on the page. Making it lazy frees only what nothing else imports. Our sidebar pulled in 1,210 KB gzipped, and making it lazy would have freed 23 KB.
next analyze (the 16.5 canary) exports the module graph:
next analyze --output --snapshot before
next analyze export --snapshot before | gzip > before.jsonl.gz
Then compare both numbers for a candidate:
// exclusive.mjs: what a module pulls into a route, and what making it lazy would free.
// gzip -dc before.jsonl.gz | node exclusive.mjs '/[locale]/app/page' components/sidebar.tsx
import { createInterface } from 'node:readline'
const [route, ...candidates] = process.argv.slice(2)
const modules = new Map()
const gzipped = new Map()
const roots = new Set()
for await (const line of createInterface({ input: process.stdin })) {
if (line.startsWith('{"type":"module"')) {
const record = JSON.parse(line)
if (record.ident.includes('[app-client]')) {
modules.set(record.ident, { path: record.path, deps: record.dependencies?.sync ?? [] })
}
} else if (line.includes(`"route":${JSON.stringify(route)}`)) {
const record = JSON.parse(line)
if (record.type === 'route') {
for (const entry of record.entries ?? []) {
for (const ref of entry.client_references ?? []) roots.add(ref.module_ident)
}
}
if (record.type === 'part' && record.filename.includes('/static/')) {
gzipped.set(record.source_path, Math.max(gzipped.get(record.source_path) ?? 0, record.compressed_size))
}
}
}
const walk = (starts, cut) => {
const blocked = (id) => cut !== undefined && modules.get(id).path.endsWith(cut)
const stack = starts.filter((id) => modules.has(id) && !blocked(id))
const seen = new Set(stack)
while (stack.length > 0) {
for (const dep of modules.get(stack.pop()).deps) {
if (!modules.has(dep) || seen.has(dep) || blocked(dep)) continue
seen.add(dep)
stack.push(dep)
}
}
return new Set([...seen].map((id) => modules.get(id).path))
}
const kb = (paths) => Math.round([...paths].reduce((sum, path) => sum + (gzipped.get(path) ?? 0), 0) / 1024)
const all = walk([...roots])
console.log(`${route}: ${all.size} modules, ${kb(all)} KB gzipped`)
for (const candidate of candidates) {
const own = [...modules].filter(([, mod]) => mod.path.endsWith(candidate)).map(([id]) => id)
const left = walk([...roots], candidate)
const freed = [...all].filter((path) => !left.has(path))
console.log(`${candidate}: pulls in ${kb(walk(own))} KB, lazy would free ${kb(freed)} KB`)
}
Only the second number tells you whether a change is worth making. Run it on the components every page mounts but rarely uses: a closed panel, a search box, a dialog. Our results:
- A chat panel loaded its workspace lazily, but the
Suspensefallback, the workspace drawn as it loads, was a static import holding the real composer. Lazy-loading the fallback too freed 129 KB on every page. - The sidebar's search parsed queries on the device with a 23 KB weights file. Loading the parser in the search handler (
await import(...)) freed 28 KB. - A shared media gallery mounted its upload dialog and its review dialog, closed, on every page that shows files. Mounting each one the first time it opens (with
next/dynamic) freed 95 to 226 KB per page.
Keep each import() in a function at module level, not inside a component. The React Compiler can't compile a component that contains one and leaves it unmemoized.
6. Look for two copies of one package
When a workspace package pins a different version of a dependency than the app does, the browser gets both copies. Our UI package pinned motion 14.6.0 while the app used 14.7.1, which put 81 KB of duplicate animation code on every page. List every resolved copy:
find . -path '*/node_modules/motion/package.json' -not -path '*/.next/*' \
-exec sh -c 'echo "$(node -p "require(\"./$1\").version") $1"' _ {} \;
Put the version in one place (a Bun or pnpm catalog:, or the root package.json) and point every workspace at it.
7. Keep the build inside your machine's RAM
experimental.turbopackFileSystemCacheForBuildspeeds up repeat builds, and our warm cache saved in about a minute. A cold cache (a fresh clone or worktree) wrote about 3 GB over 17 to 25 minutes after the build had finished, and swapped a 24 GB Mac. Turn it off for one-off builds on a laptop.experimental.cpussets the number of static generation workers, and each one loads the whole server bundle. The default is one per core minus one. We set 4.bun run --bun next buildruns the build on Bun and produced the same output for us. Bun has no V8 heap limit, so a build that would stop at Node's--max-old-space-sizekeeps taking memory instead. Keep a Node script with a heap limit for when that happens.
8. Know what the upload costs
vercel deploy --prebuilt uploads the build as a gzipped archive. Gzip only finds repeats within its last 32 KB, so the same chunk list in 60 manifests is compressed 60 times. Shrinking the manifests shrinks the upload. Building on Vercel (vercel deploy without --prebuilt) uploads your source instead of the build output.
Results
| Next 16.4.3 | After these steps | |
|---|---|---|
| Client reference manifests | 2,209 MB (419 MB gzipped) | 268 MB (6 MB gzipped) |
| Largest page manifest | 92 MB | 28 MB |
| One team page's manifest | 17 MB | 1.1 MB |
| Signed-in layout, browser JS | 279 chunks, 6.4 MB gzipped | 85 chunks, 1.6 MB gzipped |
| Unique files traced into functions | 2.5 GB | 720 MB |
| Median function size | 73 MB | 74 MB |
FAQ
Why is my Next.js deploy so big?
Most often the client reference manifests. Each page's server function has one, and it repeats a list of chunks for every client component the page renders. Small server chunks (Next 16.4's 4 KB default) and many client components multiply into manifests of tens of MB per page.
What is page_client-reference-manifest.js?
A file Next.js writes next to every App Router page's server code. It maps each client component the page renders to the browser and server-rendering chunks that load it. The server reads it on every cold start to render the page.
Does a smaller deploy make the site faster?
Partly. A smaller manifest means less to read and evaluate on each cold start, and a smaller upload means faster deploys. The browser only gets faster when its own JavaScript shrinks, as in steps 4 to 6.
Less admin. More music.
More guides
CADCraft is a free, open-source CAD and drafting app. How artists draw stage plots, studio layouts and set designs with it, and send them with the show.
DeckCraft is a free, open-source presentation app. When an artist needs a deck, and when a pitch link and a press kit page work better.
DesignCraft is a free, open-source page layout app. How artists lay out posters, one-sheets and vinyl booklets with it, and get them to fans and promoters.