Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 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,44 @@ 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.

This feature is opt-in. Enable it with the `vercel.immutableStaticFiles` option or by setting the `NITRO_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 generated asset paths, providing a way to rotate them.
::

::warning
Immutable static files must be served from the reserved `/_vercel/immutable/` path, so they are not supported when using a non-root `baseURL`. In that case Nitro skips the immutable output and warns during the build.
::

::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
68 changes: 67 additions & 1 deletion src/build/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,36 @@ 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;
}

// Server environments render public asset URLs (`?url` imports, font CSS)
// derived from their `assetsDir`, so it must point at the immutable base
// where the client build actually emits the files. Only asset naming is
// aligned (`assetsOnly`): entry/chunk filenames are left at their defaults
// so each service keeps a flat entry that frameworks import by path.
const nitro = useNitro(ctx);
if (name !== "nitro" && nitro.options.buildAssetsDir) {
config.build!.assetsDir = nitro.options.buildAssetsDir;
useLongerAssetHashes(config.build!, ctx._isRolldown, nitro.options.buildAssetsDir, {
assetsOnly: true,
});
}

// Skip if already registered as a service
if (name === "nitro" || ctx.services[name]) {
return;
Expand Down Expand Up @@ -473,6 +497,48 @@ async function setupNitroContext(
});
}

// Upgrade the default `[hash]` filename token to a longer content hash for a
// build environment's 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.
//
// Applied to the client environment (all output) and the SSR service
// environment (`assetsOnly` — just `assetFileNames`) so a shared asset
// resolves to the same filename on both sides. The SSR bundle's own
// entry/chunks keep Vite's flat server layout. A user/framework that
// overrides `assetFileNames` is responsible for keeping the two in sync
// (and such assets opt out of the `buildAssetsDir` immutable base).
Comment thread
pi0 marked this conversation as resolved.
export function useLongerAssetHashes(
build: NonNullable<EnvironmentOptions["build"]>,
isRolldown: boolean | undefined,
assetsDir: string,
opts?: { assetsOnly?: boolean }
): void {
const options = ((build as any)[isRolldown ? "rolldownOptions" : "rollupOptions"] ??= {});
const outputs = Array.isArray(options.output) ? options.output : [(options.output ??= {})];
const defaults: Record<string, string> = {
...(opts?.assetsOnly
? {}
: {
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
14 changes: 14 additions & 0 deletions src/config/resolvers/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,18 @@ import { withLeadingSlash, withTrailingSlash } from "ufo";

export async function resolveURLOptions(options: NitroOptions) {
options.baseURL = withLeadingSlash(withTrailingSlash(options.baseURL));

if (options.buildAssetsDir !== undefined) {
options.buildAssetsDir = normalizeBuildAssetsDir(options.buildAssetsDir) || undefined;
}
}

// Normalize a build assets directory into a bare relative path, stripping
// leading, trailing and duplicate slashes as well as `.` / `..` segments, so it
// can be safely interpolated into bundler filename and glob patterns.
function normalizeBuildAssetsDir(dir: string): string {
return dir
.split("/")
.filter((segment) => segment && segment !== "." && segment !== "..")
.join("/");
}
134 changes: 134 additions & 0 deletions src/presets/vercel/immutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { glob } from "tinyglobby";
import { resolve } from "pathe";
import { joinURL, withLeadingSlash } from "ufo";
import { provider } from "std-env";
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 URL to its full content hash. The
// spec allows using the file path itself as the "full content hash" when the
// name already carries enough entropy, which is what we do: emitted file names
// embed a 16 character content hash (see `useLongerAssetHashes`).

const IMMUTABLE_MANIFEST = "immutable.json";

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

// Opt-in guard for immutable static files, run from `build:before`.
//
// Immutable static files are opt-in via `vercel.immutableStaticFiles` (defaulting
// to the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` env var). When running on
// Vercel, the platform advertises support through `VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`.
// If the feature is enabled but the current deployment does not support it, or the
// app is served from a non-root `baseURL`, an immutable deploy would fail or silently
// not apply, so we disable it and warn instead of producing broken output.
export function setupImmutableStaticFiles(nitro: Nitro) {
if (!nitro.options.vercel?.immutableStaticFiles) {
return;
}

if (provider === "vercel" && !process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED) {
nitro.options.vercel.immutableStaticFiles = false;
nitro.logger
.withTag("vercel")
.warn(
"Immutable static files are enabled but not supported by the current Vercel deployment (`VERCEL_IMMUTABLE_STATIC_FILES_ENABLED` is not set). Skipping immutable static files output."
);
return;
}

// `publicDir` is nested under `baseURL`, so a non-root base would emit assets
// to `/<base>/_vercel/immutable/...` instead of the reserved `/_vercel/immutable/`
// namespace. Vercel would not treat those as immutable, so opt out instead.
if (nitro.options.baseURL !== "/") {
nitro.options.vercel.immutableStaticFiles = false;
nitro.logger
.withTag("vercel")
.warn(
`Immutable static files are not supported with a non-root \`baseURL\` (\`${nitro.options.baseURL}\`), since they must be served from the reserved \`/_vercel/immutable/\` path. Skipping immutable static files output.`
);
return;
}

nitro.options.buildAssetsDir = immutableDir(nitro);
}

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)
);

const logger = nitro.logger.withTag("vercel");
if (files.length === 0) {
// Emitting no immutable files almost always means the client build did not
// pick up `buildAssetsDir` (only the Nitro + Vite integration wires it up
// automatically). Warn instead of silently writing an empty manifest.
logger.warn(
`Immutable static files are enabled but no assets were emitted under \`${nitro.options.buildAssetsDir}\`. Make sure your client build uses \`nitro.options.buildAssetsDir\` as its assets directory.`
);
} else {
logger.info(`Generated immutable manifest (${files.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 normalizeBuildAssetsDir(
joinURL(
"_vercel/immutable",
process.env.VERCEL_HASH_SALT || "",
nitro.options.framework.name || ""
)
);
}

// Normalize a build assets directory into a bare relative path, stripping
// leading, trailing and duplicate slashes as well as `.` / `..` segments (a
// hand-set `VERCEL_HASH_SALT` must not escape the reserved namespace).
// Mirrors the same normalization applied to user config in `resolveURLOptions`.
function normalizeBuildAssetsDir(dir: string): string {
return dir
.split("/")
.filter((segment) => segment && segment !== "." && segment !== "..")
.join("/");
}
12 changes: 12 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 { setupImmutableStaticFiles, 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.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED,
cronHandlerRoute: "/_vercel/cron",
},
output: {
Expand All @@ -40,6 +42,9 @@ const vercel = defineNitroPreset(
"build:before": async (nitro: Nitro) => {
const logger = nitro.logger.withTag("vercel");

// Immutable static files
setupImmutableStaticFiles(nitro);

// Runtime
const runtime = await resolveVercelRuntime(nitro);
if (runtime.startsWith("bun") && !nitro.options.exportConditions!.includes("bun")) {
Expand Down Expand Up @@ -112,6 +117,7 @@ const vercel = defineNitroPreset(
},
async compiled(nitro: Nitro) {
await generateFunctionFiles(nitro);
await generateImmutableManifest(nitro);
},
},
},
Expand All @@ -129,6 +135,7 @@ const vercelStatic = defineNitroPreset(
},
vercel: {
skewProtection: !!process.env.VERCEL_SKEW_PROTECTION_ENABLED,
immutableStaticFiles: !!process.env.NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED,
},
output: {
dir: "{{ rootDir }}/.vercel/output",
Expand All @@ -138,11 +145,16 @@ const vercelStatic = defineNitroPreset(
preview: "npx serve ./static",
},
hooks: {
"build:before": (nitro: Nitro) => {
// Immutable static files (opt-in, guarded by Vercel platform support)
setupImmutableStaticFiles(nitro);
},
"rollup:before": (nitro: Nitro) => {
deprecateSWR(nitro);
},
async compiled(nitro: Nitro) {
await generateStaticFiles(nitro);
await generateImmutableManifest(nitro);
},
},
},
Expand Down
17 changes: 17 additions & 0 deletions src/presets/vercel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,23 @@ 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 listing each emitted immutable file.
*
* Opt in by setting this to `true` or the `NITRO_VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`
* environment variable. When building on Vercel, immutable output is only
* produced if the deployment supports it (`VERCEL_IMMUTABLE_STATIC_FILES_ENABLED`);
* otherwise it is skipped with a warning.
*
* @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
Loading
Loading