Skip to content

vercel preset: functionRules forces full bundle copy per route, blowing up output size #4233

@shiroyasha9

Description

@shiroyasha9

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:

  1. Disk usage scales linearly with functionRules.length, even when overrides only differ by maxDuration.
  2. 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.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions