Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b4cfac8
feat: initial work on vercel immedutable
pi0 Jul 15, 2026
4e44a93
rename config to buildAssetsDir
pi0 Jul 15, 2026
d0e3a6c
chore: apply automated updates
autofix-ci[bot] Jul 15, 2026
adaadc5
change gate
pi0 Jul 15, 2026
4472a85
update docs
pi0 Jul 15, 2026
73c4b09
support VERCEL_IMMUTABLE_ASSETS env
pi0 Jul 15, 2026
624311a
avoid extra hash by default
pi0 Jul 15, 2026
c4e8ded
no need to read file
pi0 Jul 15, 2026
d2f3916
simplify manifrst creation
pi0 Jul 15, 2026
8271343
respect salt
pi0 Jul 15, 2026
f2ee1f8
use long vite hashes
pi0 Jul 15, 2026
18569bb
Merge branch 'main' into feat/vercel-immutable
pi0 Jul 15, 2026
4dcc7d2
rm double slash
pi0 Jul 15, 2026
a23d80b
use filename
pi0 Jul 15, 2026
c619bd1
simplify manifest creation
pi0 Jul 15, 2026
ee4409d
chore: apply automated updates
autofix-ci[bot] Jul 15, 2026
43b5fa8
update docs
pi0 Jul 15, 2026
e5e3730
rename options
pi0 Jul 15, 2026
3a583f1
use framework name in dir
pi0 Jul 15, 2026
8964eca
add basic behavior test
pi0 Jul 15, 2026
ced17b8
use salt in test
pi0 Jul 15, 2026
eb09b4b
update test
pi0 Jul 15, 2026
02ee991
fix: match longer asset hash in ssr
RihanArfan Jul 16, 2026
eb30da5
docs: typo
RihanArfan Jul 16, 2026
6d61d11
Merge branch 'main' into feat/vercel-immutable
RihanArfan Jul 20, 2026
835f671
Merge branch 'main' into feat/vercel-immutable
RihanArfan Jul 21, 2026
009f541
refactor: use useLongerAssetHashes with ssr
RihanArfan Jul 21, 2026
eb4f0d1
refactor: make immutable opt in
RihanArfan Jul 22, 2026
d60f636
fix: apply useLongerAssetHashes to ssr env rather than all non-nitro …
RihanArfan Jul 22, 2026
e6b0e71
fix: set immutable assetsDir on all server envs without forcing entry…
RihanArfan Jul 22, 2026
06a2e65
fix(vercel): guard immutable static files and normalize buildAssetsDir
pi0 Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/2.deploy/20.providers/vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,40 @@ If your hook throws, the message is retried locally. Retries honour `retryAfterS

You can provide additional [build output configuration](https://vercel.com/docs/build-output-api/v3) using `vercel.config` key inside `nitro.config`. It will be merged with built-in auto-generated config.

## Immutable static files

::important
This feature is currently only available in the [nightly release channel](/docs/nightly) of Nitro v3.
::

<!-- :read-more{title="Immutable static files" to="https://vercel.com/docs/skew-protection"} -->

Client build assets (such as JS and CSS chunks) can be emitted as **immutable static files**. These are served from the reserved `/_vercel/immutable/` path so they are shared across deployments and keep resolving even after a newer deployment no longer references them, improving cross-deployment caching.

You can enable this feature with the `vercel.immutableStaticFiles` option or by setting the `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` environment variable.

```ts [nitro.config.ts]
import { defineConfig } from "nitro";

export default defineConfig({
vercel: {
immutableStaticFiles: true
}
})
```

When enabled, Nitro emits content-addressed build assets under `/_vercel/immutable/`.

::note
The [`VERCEL_HASH_SALT`](https://vercel.com/docs/environment-variables/system-environment-variables) system environment variable is factored into the content hashes, providing a way to rotate the generated file names.
::

::note
This works out of the box with the **Nitro + Vite** integration: Nitro's Vercel preset sets `buildAssetsDir` config and the Vite plugin automatically applies it as the `assetsDir` for the client and server-rendered builds, so all generated asset URLs point under `/_vercel/immutable/`.

Client build setups and frameworks must apply `nitro.options.buildAssetsDir` themselves — use it as the client bundler's asset output directory (base) so generated asset URLs are emitted under that path. Without this, assets are still emitted at their default location and the immutable manifest will not match.
::

## On-Demand incremental static regeneration (ISR)

On-demand revalidation allows you to purge the cache for an ISR route whenever you want, foregoing the time interval required with background revalidation.
Expand Down
17 changes: 16 additions & 1 deletion src/build/vite/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,34 @@ export function createServiceEnvironment(
): EnvironmentOptions {
const isDev = ctx.nitro!.options.dev;
const isWorkerdRunner = _isWorkerdRunner(ctx);
// Keep SSR-emitted asset URLs (e.g. CSS `<link>` tags) aligned with the
// relocated client assets, so both resolve to the same content-addressed file.
// Both the directory (`assetsDir`) and the content-hash length must match the
// client environment, otherwise the SSR bundle references e.g.
// `app-<hash:8>.css` while the client emits `app-<hash:16>.css` → 404.
const buildAssetsDir = ctx.nitro!.options.buildAssetsDir;
return {
consumer: "server",
build: {
rollupOptions: {
input: { index: serviceConfig.entry },
...(isDev ? {} : { external: [/^nitro(\/|$)/] }),
output: { minifyInternalExports: false },
output: {
minifyInternalExports: false,
// Must match the client environment's `[hash:16]` asset pattern (see
// `useLongerAssetHashes` in `plugin.ts`) so a `?url` asset import
// resolves to the same filename on both sides.
...(buildAssetsDir
? { assetFileNames: `${buildAssetsDir}/[name]-[hash:16][extname]` }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: We are still blindly overriding assetFileNames for server envs

: {}),
},
},
minify: ctx.nitro!.options.minify,
sourcemap: ctx.nitro!.options.sourcemap,
outDir: join(ctx.nitro!.options.buildDir, "vite/services", name),
emptyOutDir: true,
copyPublicDir: false,
...(buildAssetsDir ? { assetsDir: buildAssetsDir } : {}),
},
resolve: {
...(isDev ? { noExternal: isWorkerdRunner ? true : [/^nitro(\/|$)/] } : {}),
Expand Down
47 changes: 46 additions & 1 deletion src/build/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,20 @@ function nitroEnv(ctx: NitroPluginContext): VitePlugin {
configEnvironment(name, config) {
if (config.consumer === "client") {
debug("[env] Configuring client environment", name === "client" ? "" : ` (${name})`);
const nitro = useNitro(ctx);
config.build!.emptyOutDir = false;
config.build!.outDir = useNitro(ctx).options.output.publicDir;
config.build!.outDir = nitro.options.output.publicDir;
config.build!.copyPublicDir ??= false;
// Relocate generated client assets (e.g. under `_vercel/immutable`) so
// both client and SSR references point at the immutable base.
if (nitro.options.buildAssetsDir) {
config.build!.assetsDir = nitro.options.buildAssetsDir;
// Content-addressed (immutable) assets benefit from longer content
// hashes to reduce collision risk across deployments. Only upgrade the
// default `[hash]` token to a longer one; never override filename
// patterns explicitly set by the user or other plugins.
useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir);
}
return;
}

Expand Down Expand Up @@ -473,6 +484,40 @@ async function setupNitroContext(
});
}

// Upgrade the default `[hash]` filename token to a longer content hash for the
// client build output. Filename patterns already configured (by the user or
// other plugins) are only touched to lengthen a bare `[hash]`; explicit
// `[hash:n]` tokens and non-string patterns are left untouched.
//
// NOTE: the `[hash:16]` asset pattern must stay in sync with the SSR service
// environment's `assetFileNames` (see `env.ts`), otherwise a `?url` asset import
// resolves to a different filename on the client vs. the server → 404.
export function useLongerAssetHashes(
build: NonNullable<EnvironmentOptions["build"]>,
isRolldown: boolean | undefined,
assetsDir: string
): void {
const options = ((build as any)[isRolldown ? "rolldownOptions" : "rollupOptions"] ??= {});
const outputs = Array.isArray(options.output) ? options.output : [(options.output ??= {})];
const defaults: Record<string, string> = {
entryFileNames: `${assetsDir}/[name]-[hash:16].js`,
chunkFileNames: `${assetsDir}/[name]-[hash:16].js`,
assetFileNames: `${assetsDir}/[name]-[hash:16][extname]`,
};
for (const output of outputs) {
for (const key of Object.keys(defaults)) {
const current = output[key];
if (current === undefined) {
// Not set: opt into a longer-hash default matching Vite's own pattern.
output[key] = defaults[key];
} else if (typeof current === "string" && current.includes("[hash]")) {
// Already set: only lengthen a bare `[hash]` token, keep the rest as-is.
output[key] = current.replaceAll("[hash]", `[hash:16]`);
}
}
}
}

function getEntry(input: InputOption | undefined): string | undefined {
if (typeof input === "string") {
return input;
Expand Down
71 changes: 71 additions & 0 deletions src/presets/vercel/immutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { glob } from "tinyglobby";
import { resolve } from "pathe";
import { joinURL, withLeadingSlash } from "ufo";
import type { Nitro } from "nitro/types";
import { writeFile } from "../_utils/fs.ts";

// Immutable Static Files
//
// https://vercel.com/docs/build-output-api/primitives
//
// Immutable static files are content-addressed and shared across deployments which improves cross-deployment
// caching.
//
// Newer deployments must never overwrite an existing file with different content, so the file names embed a content hash.
//
// Alongside `.vercel/output/static`, we emit a `.vercel/output/immutable.json`
// manifest mapping each immutable static file to its full content hash (the file
// name may only contain a truncated hash).

const IMMUTABLE_MANIFEST = "immutable.json";

interface ImmutableManifest {
version: 1;
hashes: Record<string, string>;
}

export async function generateImmutableManifest(nitro: Nitro) {
// Skip unless immutable static files are enabled (`vercel.immutableStaticFiles`).
if (!nitro.options.vercel?.immutableStaticFiles) {
return;
}

const publicDir = nitro.options.output.publicDir;
const files = await glob(`${nitro.options.buildAssetsDir}/**`, {
cwd: publicDir,
absolute: false,
dot: true,
});

const manifest: ImmutableManifest = {
version: 1,
hashes: Object.fromEntries(
files.map((file) => {
const pathname = withLeadingSlash(file);
const url = joinURL(nitro.options.baseURL, pathname);
return [url, url];
})
),
};

await writeFile(
resolve(nitro.options.output.dir, IMMUTABLE_MANIFEST),
JSON.stringify(manifest, null, 2)
);

nitro.logger
.withTag("vercel")
.info(`Generated immutable manifest (${Object.keys(manifest.hashes).length} files).`);
}

// Reserved Vercel namespace under which immutable static files are served.
// Used as the client `buildAssetsDir` so content-addressed assets are emitted
// and referenced here. The path is namespaced by an optional hash salt and the
// framework name to avoid cross-framework collisions.
export function immutableDir(nitro: Nitro) {
return joinURL(
"_vercel/immutable",
process.env.VERCEL_HASH_SALT || "",
nitro.options.framework.name || ""
);
}
17 changes: 17 additions & 0 deletions src/presets/vercel/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
generateStaticFiles,
resolveVercelRuntime,
} from "./utils.ts";
import { immutableDir, generateImmutableManifest } from "./immutable.ts";
import { vercelDevModule } from "./dev.ts";

import type { VercelFunctionTrigger } from "./types.ts";
Expand All @@ -25,6 +26,7 @@ const vercel = defineNitroPreset(
},
vercel: {
skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED,
immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED,
cronHandlerRoute: "/_vercel/cron",
},
output: {
Expand All @@ -40,6 +42,13 @@ const vercel = defineNitroPreset(
"build:before": async (nitro: Nitro) => {
const logger = nitro.logger.withTag("vercel");

// Immutable static files: emit content-addressed build assets under the
// reserved `_vercel/immutable` base so they can be shared across
// deployments.
if (nitro.options.vercel?.immutableStaticFiles) {
nitro.options.buildAssetsDir = immutableDir(nitro);
}

// Runtime
const runtime = await resolveVercelRuntime(nitro);
if (runtime.startsWith("bun") && !nitro.options.exportConditions!.includes("bun")) {
Expand Down Expand Up @@ -112,6 +121,7 @@ const vercel = defineNitroPreset(
},
async compiled(nitro: Nitro) {
await generateFunctionFiles(nitro);
await generateImmutableManifest(nitro);
},
},
},
Expand All @@ -129,6 +139,7 @@ const vercelStatic = defineNitroPreset(
},
vercel: {
skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED,
immutableStaticFiles: !!process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED,
},
output: {
dir: "{{ rootDir }}/.vercel/output",
Expand All @@ -138,11 +149,17 @@ const vercelStatic = defineNitroPreset(
preview: "npx serve ./static",
},
hooks: {
"build:before": (nitro: Nitro) => {
if (nitro.options.vercel?.immutableStaticFiles) {
nitro.options.buildAssetsDir = immutableDir(nitro);
}
},
"rollup:before": (nitro: Nitro) => {
deprecateSWR(nitro);
},
async compiled(nitro: Nitro) {
await generateStaticFiles(nitro);
await generateImmutableManifest(nitro);
},
},
},
Expand Down
14 changes: 14 additions & 0 deletions src/presets/vercel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ export interface VercelOptions {
*/
skewProtection?: boolean;

/**
* Emit content-addressed build assets as [immutable static files](https://vercel.com/docs/skew-protection).
*
* Immutable assets are served from the reserved `/_vercel/immutable/` base
* (without the deployment-scoped `?dpl` query), so they can be shared across
* deployments to improve cross-deployment caching. Nitro also writes an
* `immutable.json` manifest mapping each file to its full content hash.
*
* Enabled by default. Set to `false` to opt out.
*
* @default false
*/
immutableStaticFiles?: boolean;

/**
* If you are using `vercel-edge`, you can specify the region(s) for your edge function.
* @see https://vercel.com/docs/concepts/functions/edge-functions#edge-function-regions
Expand Down
10 changes: 10 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ export interface NitroOptions extends PresetOptions {
publicDir: string;
};

/**
* Directory (relative to the served base) for generated build assets.
*
* Applied as the bundler `assetsDir` for client and SSR environments, so
* generated assets are emitted and referenced under this path. Presets can
* use it to relocate content-addressed assets (e.g. Vercel immutable static
* files under `_vercel/immutable`).
*/
buildAssetsDir?: string;

/** @deprecated Migrate to `serverDir`. */
srcDir: string;

Expand Down
1 change: 1 addition & 0 deletions test/fixture/nitro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { existsSync } from "node:fs";

export default defineConfig({
vercel: {
immutableStaticFiles: true,
functionRules: {
"/api/hello": {
maxDuration: 100,
Expand Down
37 changes: 37 additions & 0 deletions test/presets/vercel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { promises as fsp } from "node:fs";
import { Server, type IncomingMessage, type ServerResponse } from "node:http";
import type { Socket } from "node:net";
import { resolve, join, basename } from "pathe";
import { joinURL } from "ufo";
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import { setupTest, testNitro, fixtureDir } from "../tests.ts";
import { toFetchHandler } from "srvx/node";
Expand All @@ -11,7 +12,16 @@ const presetFixturesDir = resolve(import.meta.dirname, "fixtures");
// NOTE: Always prefer extending the existing `nitro:preset:vercel:web` matrix
// (its setup/build is shared across assertions) over adding new top-level
// `describe` blocks, which trigger a separate build and slow down CI.

describe("nitro:preset:vercel:web", async () => {
const TEST_HASH_SALT = "initial";

// Example salt used to exercise `VERCEL_HASH_SALT` namespacing of immutable
// static files. Set around the (build-time) `setupTest` below and restored
// immediately after so it does not leak into other builds.
const prevHashSalt = process.env.VERCEL_HASH_SALT;
process.env.VERCEL_HASH_SALT = TEST_HASH_SALT;

const ctx = await setupTest("vercel", {
outDirSuffix: "-web",
config: {
Expand All @@ -32,6 +42,13 @@ describe("nitro:preset:vercel:web", async () => {
},
},
});

if (prevHashSalt === undefined) {
delete process.env.VERCEL_HASH_SALT;
} else {
process.env.VERCEL_HASH_SALT = prevHashSalt;
}

testNitro(
ctx,
async () => {
Expand Down Expand Up @@ -454,6 +471,26 @@ describe("nitro:preset:vercel:web", async () => {
`);
});

it("should apply immutable buildAssetsDir and write manifest", async () => {
// `vercel.immutableStaticFiles` relocates build assets under the
// reserved `_vercel/immutable` base (namespaced by the optional
// `VERCEL_HASH_SALT` and the framework name) and emits an
// `immutable.json` manifest mapping each file to its full content hash.
const expectedDir = joinURL(
"_vercel/immutable",
TEST_HASH_SALT,
ctx.nitro!.options.framework.name || ""
);
expect(ctx.nitro!.options.buildAssetsDir).toBe(expectedDir);
expect(expectedDir).toBe(`_vercel/immutable/${TEST_HASH_SALT}/nitro`);

const manifest = await fsp
.readFile(resolve(ctx.outDir, "immutable.json"), "utf8")
.then((r) => JSON.parse(r));
expect(manifest.version).toBe(1);
expect(manifest.hashes).toBeTypeOf("object");
});

it("should generate prerender config", async () => {
const isrRouteConfig = await fsp.readFile(
resolve(ctx.outDir, "functions/rules/isr/[...]-isr.prerender-config.json"),
Expand Down
Loading