Environment
- nitro:
3.0.1-20260426-013439-feebdc1e (nitro-nightly)
- node: 22.x
- preset:
vercel
- consumer:
@tanstack/react-start@1.167.50
Reproduction
Any Nitro app on the vercel preset that declares vercel.functionRules for N routes will produce N full copies of __server.func/ under .vercel/output/functions/.
// nitro.config.ts (or vite.config.ts via @tanstack/react-start)
nitro({
vercel: {
functions: { maxDuration: 60 },
functionRules: {
"/api/long-a": { maxDuration: 180 },
"/api/long-b": { maxDuration: 180 },
// ... 10+ entries common in real apps
},
},
})
After build, du -sh .vercel/output/functions/api/*.func shows each entry equal in size to __server.func/. In our app: 173MB per func, 15 entries = 2.5GB on disk, 575MB tgz upload via vercel deploy --prebuilt --archive=tgz. Removing functionRules entirely drops the same build to 269MB on disk and 45MB tgz.
I have not put together a starter-template repro yet, but the bug is provable from the source path quoted below plus the size delta. Happy to put one together if maintainers want it.
Describe the bug
Compiled at node_modules/nitro/dist/_presets.mjs:1597 (source: src/presets/vercel/preset.ts, introduced by #4124, closes #3815):
async function createFunctionDirWithCustomConfig(funcDir, serverDir, baseFunctionConfig, overrides, functionPath) {
await fsp.cp(serverDir, funcDir, { recursive: true });
// ...writes per-route .vc-config.json
}
Every entry in functionRules triggers a full recursive copy of the server bundle. Routes without custom config use fsp.symlink (line ~1410), which is cheap. The expensive fsp.cp exists only because each .func/ dir needs its own .vc-config.json with the per-route overrides.
The Vercel Build Output API does not require the rest of the function dir to be a real copy. Vercel follows symlinks at upload, and unzips each .func/ independently on its servers. So a .func/ whose code files are symlinks into a shared dir, but whose .vc-config.json is a real file, would deploy correctly.
Concrete impact:
- Disk usage scales linearly with
functionRules.length, even when overrides only differ by maxDuration.
vercel deploy --prebuilt --archive=tgz follows symlinks, so the same content gets tarred N times. gzip dictionary helps across blocks, but the tgz still grows substantially. We measured 575MB vs 45MB for the same code with vs without functionRules.
- Each
.func/ unzips separately on Vercel servers and counts against the 250MB unzipped per function limit. Apps near that limit can exceed it on every functionRules entry.
- None of this is documented. The Vercel preset docs present
functionRules as the recommended way to set per-route maxDuration with no warning about bundle duplication.
Proposed fix
Replace the recursive fsp.cp with a per-file symlink farm plus a single real .vc-config.json. Sketch:
async function createFunctionDirWithCustomConfig(funcDir, serverDir, baseFunctionConfig, overrides, functionPath) {
await fsp.mkdir(funcDir, { recursive: true });
for (const entry of await fsp.readdir(serverDir, { withFileTypes: true })) {
if (entry.name === ".vc-config.json") continue; // per-route file, written below
const src = resolve(serverDir, entry.name);
const dest = resolve(funcDir, entry.name);
await fsp.symlink(relative(funcDir, src), dest, entry.isDirectory() ? "junction" : "file");
}
const mergedConfig = defu(overrides, baseFunctionConfig);
// ...existing trigger handling...
await writeFile(resolve(funcDir, ".vc-config.json"), JSON.stringify(mergedConfig, null, 2));
}
Keeps per-route config flexibility while collapsing N copies of the server bundle into one shared dir. Should not affect deploy correctness because Vercel resolves symlinks at upload time.
Additional context
Workaround for affected users: pick one global vercel.functions.maxDuration generous enough for every route, then drop all functionRules entries that only differ by maxDuration. We saw a 92% reduction in deploy archive size (575MB to 45MB) doing this.
Related: #3815 (original feature request), #4124 (PR introducing functionRules), #4174 (v2 backport, draft).
If the maintainers consider the symlink-farm approach acceptable, happy to send a PR.
Logs
# before, with 15 functionRules entries
$ du -sh .vercel/output
2.5G .vercel/output
$ du -sh .vercel/output/functions/api/*.func | head -3
173M .vercel/output/functions/api/generate.func
173M .vercel/output/functions/api/generatePreset.func
173M .vercel/output/functions/api/v2/coverage-compute.func
# after, no functionRules entries
$ du -sh .vercel/output
269M .vercel/output
$ ls .vercel/output/functions/
__server.func
Environment
3.0.1-20260426-013439-feebdc1e(nitro-nightly)vercel@tanstack/react-start@1.167.50Reproduction
Any Nitro app on the
vercelpreset that declaresvercel.functionRulesfor N routes will produce N full copies of__server.func/under.vercel/output/functions/.After build,
du -sh .vercel/output/functions/api/*.funcshows each entry equal in size to__server.func/. In our app: 173MB per func, 15 entries = 2.5GB on disk, 575MB tgz upload viavercel deploy --prebuilt --archive=tgz. RemovingfunctionRulesentirely drops the same build to 269MB on disk and 45MB tgz.I have not put together a starter-template repro yet, but the bug is provable from the source path quoted below plus the size delta. Happy to put one together if maintainers want it.
Describe the bug
Compiled at
node_modules/nitro/dist/_presets.mjs:1597(source:src/presets/vercel/preset.ts, introduced by #4124, closes #3815):Every entry in
functionRulestriggers a full recursive copy of the server bundle. Routes without custom config usefsp.symlink(line ~1410), which is cheap. The expensivefsp.cpexists only because each.func/dir needs its own.vc-config.jsonwith the per-route overrides.The Vercel Build Output API does not require the rest of the function dir to be a real copy. Vercel follows symlinks at upload, and unzips each
.func/independently on its servers. So a.func/whose code files are symlinks into a shared dir, but whose.vc-config.jsonis a real file, would deploy correctly.Concrete impact:
functionRules.length, even when overrides only differ bymaxDuration.vercel deploy --prebuilt --archive=tgzfollows symlinks, so the same content gets tarred N times. gzip dictionary helps across blocks, but the tgz still grows substantially. We measured 575MB vs 45MB for the same code with vs withoutfunctionRules..func/unzips separately on Vercel servers and counts against the 250MB unzipped per function limit. Apps near that limit can exceed it on everyfunctionRulesentry.functionRulesas the recommended way to set per-routemaxDurationwith no warning about bundle duplication.Proposed fix
Replace the recursive
fsp.cpwith a per-file symlink farm plus a single real.vc-config.json. Sketch:Keeps per-route config flexibility while collapsing N copies of the server bundle into one shared dir. Should not affect deploy correctness because Vercel resolves symlinks at upload time.
Additional context
Workaround for affected users: pick one global
vercel.functions.maxDurationgenerous enough for every route, then drop allfunctionRulesentries that only differ bymaxDuration. We saw a 92% reduction in deploy archive size (575MB to 45MB) doing this.Related: #3815 (original feature request), #4124 (PR introducing
functionRules), #4174 (v2 backport, draft).If the maintainers consider the symlink-farm approach acceptable, happy to send a PR.
Logs