diff --git a/src/presets/vercel/utils.ts b/src/presets/vercel/utils.ts
index 4f6d3158e3..731ba64dc6 100644
--- a/src/presets/vercel/utils.ts
+++ b/src/presets/vercel/utils.ts
@@ -2,7 +2,7 @@ import fsp from "node:fs/promises";
import { constants } from "node:fs";
import { defu } from "defu";
import { writeFile } from "../_utils/fs.ts";
-import type { Nitro, NitroRouteRules, ProxyRuleOptions } from "nitro/types";
+import type { Nitro, NitroRouteRules, PrerenderRoute, ProxyRuleOptions } from "nitro/types";
import { basename, dirname, relative, resolve } from "pathe";
import { Router } from "../../routing.ts";
import { joinURL, withLeadingSlash, withoutLeadingSlash } from "ufo";
@@ -37,6 +37,12 @@ const ISR_SUFFIX = "-isr"; // Avoid using . as it can conflict with routing
const SAFE_FS_CHAR_RE = /[^a-zA-Z0-9_.[\]/]/g;
+// Vercel serves `
/index.html` (and extensionless `/index`) at ``
+// using built-in directory indexes.
+const INDEX_FILE_RE = /(^|\/)index(\.html)?$/;
+
+const SURROUNDING_SLASH_RE = /^\/+|\/+$/g;
+
function getSystemNodeVersion() {
const systemNodeVersion = Number.parseInt(process.versions.node.split(".")[0]);
@@ -229,17 +235,7 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
name: nitro.options.framework.name,
version: nitro.options.framework.version,
},
- overrides: {
- // Nitro static prerendered route overrides
- ...Object.fromEntries(
- (nitro._prerenderedRoutes?.filter((r) => r.fileName !== r.route) || []).map(
- ({ route, fileName }) => [
- withoutLeadingSlash(fileName),
- { path: route.replace(/^\//, "") },
- ]
- )
- ),
- },
+ overrides: getPrerenderOverrides(nitro._prerenderedRoutes),
routes: [
// Redirect and header rules (excluding paths handled as CDN proxy rewrites)
...rules
@@ -390,6 +386,38 @@ function generateBuildConfig(nitro: Nitro, o11Routes?: ObservabilityRoute[]) {
return config;
}
+/**
+ * Map prerendered files to the route they should be served from.
+ *
+ * Paths are always slash-free: Vercel strips slashes when matching, so a path
+ * that keeps a trailing slash matches nothing at all (#4392), and the root
+ * route has to map to an empty path (ufo's slash helpers cannot produce one).
+ *
+ * Files that Vercel already serves at the route using its built-in directory
+ * indexes are skipped.
+ */
+export function getPrerenderOverrides(prerenderedRoutes: PrerenderRoute[] = []) {
+ const overrides: Record = {};
+
+ for (const { route, fileName } of prerenderedRoutes) {
+ if (!fileName) {
+ continue;
+ }
+ const file = withoutLeadingSlash(fileName);
+ const path = route.replace(SURROUNDING_SLASH_RE, "");
+ // Skip when Vercel already serves the file at `path`: either via its
+ // built-in directory index (`/index.*` at ``), or because the
+ // file already lives there. Re-keying a file onto its own path would
+ // delete it, since Vercel drops the original entry.
+ if (file === path || file.replace(INDEX_FILE_RE, "") === path) {
+ continue;
+ }
+ overrides[file] = { path };
+ }
+
+ return overrides;
+}
+
export function deprecateSWR(nitro: Nitro) {
if (nitro.options.future.nativeSWR) {
return;
@@ -500,13 +528,23 @@ type ObservabilityRoute = {
dest: string; // function name
};
-function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] {
+export function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] {
const compatDate =
nitro.options.compatibilityDate.vercel || nitro.options.compatibilityDate.default;
if (compatDate < "2025-07-15") {
return [];
}
+ // Vercel resolves functions and static files from a single path to output
+ // map that functions are added to last, so a function at the path of a
+ // prerendered file hides that file and serves the route with SSR on every
+ // request (#4242).
+ const prerenderedPaths = new Set(
+ (nitro._prerenderedRoutes || [])
+ .filter((route) => route.fileName)
+ .map((route) => route.route.replace(SURROUNDING_SLASH_RE, ""))
+ );
+
// Sort routes by how much specific they are
const routePatterns = [
...new Set([
@@ -515,7 +553,7 @@ function getObservabilityRoutes(nitro: Nitro): ObservabilityRoute[] {
.filter((h) => !h.middleware && h.route)
.map((h) => h.route!),
]),
- ];
+ ].filter((route) => !prerenderedPaths.has(route.replace(SURROUNDING_SLASH_RE, "")));
const staticRoutes: string[] = [];
const dynamicRoutes: string[] = [];
diff --git a/test/presets/fixtures/slash.ts b/test/presets/fixtures/slash.ts
new file mode 100644
index 0000000000..2ec73828f3
--- /dev/null
+++ b/test/presets/fixtures/slash.ts
@@ -0,0 +1,6 @@
+import { defineHandler } from "nitro/h3";
+
+export default defineHandler((event) => {
+ event.res.headers.set("content-type", "text/html");
+ return "slash";
+});
diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts
index ae4d31e71a..3eead03302 100644
--- a/test/presets/vercel.test.ts
+++ b/test/presets/vercel.test.ts
@@ -21,7 +21,15 @@ describe("nitro:preset:vercel:web", async () => {
route: "/_ws",
handler: resolve(presetFixturesDir, "websocket.ts"),
},
+ {
+ route: "/slash",
+ handler: resolve(presetFixturesDir, "slash.ts"),
+ },
],
+ prerender: {
+ // trailing slash on purpose (#4392)
+ routes: ["/slash/"],
+ },
vercel: {
queues: {
triggers: [
@@ -63,20 +71,7 @@ describe("nitro:preset:vercel:web", async () => {
"name": "nitro",
"version": "3.x",
},
- "overrides": {
- "_scalar/index.html": {
- "path": "_scalar",
- },
- "_swagger/index.html": {
- "path": "_swagger",
- },
- "api/hey/index.html": {
- "path": "api/hey",
- },
- "prerender/index.html": {
- "path": "prerender",
- },
- },
+ "overrides": {},
"routes": [
{
"headers": {
@@ -260,14 +255,6 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/raw",
"src": "/raw",
},
- {
- "dest": "/prerender-custom.html",
- "src": "/prerender-custom.html",
- },
- {
- "dest": "/prerender",
- "src": "/prerender",
- },
{
"dest": "/node-compat",
"src": "/node-compat",
@@ -280,10 +267,6 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/jsx",
"src": "/jsx",
},
- {
- "dest": "/json-string",
- "src": "/json-string",
- },
{
"dest": "/imports",
"src": "/imports",
@@ -360,14 +343,6 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/api/kebab",
"src": "/api/kebab",
},
- {
- "dest": "/api/hey",
- "src": "/api/hey",
- },
- {
- "dest": "/api/hello",
- "src": "/api/hello",
- },
{
"dest": "/api/headers",
"src": "/api/headers",
@@ -400,18 +375,6 @@ describe("nitro:preset:vercel:web", async () => {
"dest": "/_vercel/cron",
"src": "/_vercel/cron",
},
- {
- "dest": "/_swagger",
- "src": "/_swagger",
- },
- {
- "dest": "/_scalar",
- "src": "/_scalar",
- },
- {
- "dest": "/_openapi.json",
- "src": "/_openapi.json",
- },
{
"dest": "/single-headers/[id]",
"src": "/single-headers/(?[^/]+)",
@@ -491,9 +454,6 @@ describe("nitro:preset:vercel:web", async () => {
[
"functions/500.func (symlink)",
"functions/__server.func",
- "functions/_openapi.json.func (symlink)",
- "functions/_scalar.func (symlink)",
- "functions/_swagger.func (symlink)",
"functions/_vercel",
"functions/_ws.func (symlink)",
"functions/api/cached.func (symlink)",
@@ -501,7 +461,6 @@ describe("nitro:preset:vercel:web", async () => {
"functions/api/echo.func",
"functions/api/headers.func (symlink)",
"functions/api/hello.func",
- "functions/api/hey.func (symlink)",
"functions/api/kebab.func (symlink)",
"functions/api/meta/test.func (symlink)",
"functions/api/methods/foo.get.func (symlink)",
@@ -526,12 +485,9 @@ describe("nitro:preset:vercel:web", async () => {
"functions/icon.png.func (symlink)",
"functions/import-attributes.func (symlink)",
"functions/imports.func (symlink)",
- "functions/json-string.func (symlink)",
"functions/jsx.func (symlink)",
"functions/modules.func (symlink)",
"functions/node-compat.func (symlink)",
- "functions/prerender-custom.html.func (symlink)",
- "functions/prerender.func (symlink)",
"functions/raw.func (symlink)",
"functions/replace.func (symlink)",
"functions/route-group.func (symlink)",
diff --git a/test/unit/vercel-observability-routes.test.ts b/test/unit/vercel-observability-routes.test.ts
new file mode 100644
index 0000000000..d4aac5d991
--- /dev/null
+++ b/test/unit/vercel-observability-routes.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from "vitest";
+import type { Nitro, NitroEventHandler, PrerenderRoute } from "nitro/types";
+
+import { getObservabilityRoutes } from "../../src/presets/vercel/utils.ts";
+
+function createNitroStub(opts: {
+ compatibilityDate?: string;
+ handlers?: NitroEventHandler[];
+ ssrRoutes?: string[];
+ prerenderedRoutes?: PrerenderRoute[];
+}): Nitro {
+ return {
+ scannedHandlers: opts.handlers || [],
+ _prerenderedRoutes: opts.prerenderedRoutes,
+ options: {
+ compatibilityDate: { default: opts.compatibilityDate || "2025-07-15" },
+ handlers: [],
+ ssrRoutes: opts.ssrRoutes || [],
+ },
+ } as unknown as Nitro;
+}
+
+const dests = (nitro: Nitro) => getObservabilityRoutes(nitro).map((route) => route.dest);
+
+describe("getObservabilityRoutes", () => {
+ it("returns no routes before the observability compatibility date", () => {
+ expect(
+ getObservabilityRoutes(
+ createNitroStub({
+ compatibilityDate: "2025-07-14",
+ handlers: [{ route: "/foo", handler: "foo.ts" }],
+ })
+ )
+ ).toEqual([]);
+ });
+
+ it("creates a route per handler, most specific first", () => {
+ expect(
+ getObservabilityRoutes(
+ createNitroStub({
+ handlers: [
+ { route: "/**", handler: "catch-all.ts" },
+ { route: "/blog/:slug", handler: "blog.ts" },
+ { route: "/foo", handler: "foo.ts" },
+ { route: "/skipped", handler: "middleware.ts", middleware: true },
+ ],
+ ssrRoutes: ["/"],
+ })
+ )
+ ).toEqual([
+ { src: "/foo", dest: "foo" },
+ { src: "/", dest: "index" },
+ { src: "/blog/(?[^/]+)", dest: "blog/[slug]" },
+ { src: "/(?:.*)", dest: "[...]" },
+ ]);
+ });
+
+ // Vercel keeps functions and static files in a single path -> output map and
+ // lets the function win, so a function at the path of a prerendered file
+ // hides it and serves the route with SSR on every request (#4242)
+ it("skips routes served by a prerendered file", () => {
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [
+ { route: "/prerendered", handler: "prerendered.ts" },
+ { route: "/dynamic", handler: "dynamic.ts" },
+ ],
+ prerenderedRoutes: [{ route: "/prerendered", fileName: "/prerendered/index.html" }],
+ })
+ )
+ ).toEqual(["dynamic"]);
+ });
+
+ it("skips prerendered ssrRoutes and explicit handlers alike", () => {
+ expect(
+ dests(
+ createNitroStub({
+ ssrRoutes: ["/from-ssr-routes"],
+ handlers: [{ route: "/from-handlers", handler: "handler.ts" }],
+ prerenderedRoutes: [
+ { route: "/from-ssr-routes", fileName: "/from-ssr-routes/index.html" },
+ { route: "/from-handlers", fileName: "/from-handlers/index.html" },
+ ],
+ })
+ )
+ ).toEqual([]);
+ });
+
+ // Vercel matches paths without surrounding slashes, so the route and the
+ // prerendered path have to be compared slash-free (#4392)
+ it("matches prerendered routes regardless of a trailing slash", () => {
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [{ route: "/slash", handler: "slash.ts" }],
+ prerenderedRoutes: [{ route: "/slash/", fileName: "/slash/index.html" }],
+ })
+ )
+ ).toEqual([]);
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [{ route: "/slash/", handler: "slash.ts" }],
+ prerenderedRoutes: [{ route: "/slash", fileName: "/slash.html" }],
+ })
+ )
+ ).toEqual([]);
+ });
+
+ // The root function is written to `index.func`, which shadows `index.html`
+ it("skips the root route when it is prerendered", () => {
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [{ route: "/", handler: "index.ts" }],
+ prerenderedRoutes: [{ route: "/", fileName: "/index.html" }],
+ })
+ )
+ ).toEqual([]);
+ });
+
+ // A dynamic function still has to serve every path that was not prerendered,
+ // and its output path never collides with a resolved prerendered path
+ it("keeps dynamic routes with prerendered leaves", () => {
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [
+ { route: "/blog/:slug", handler: "blog.ts" },
+ { route: "/docs/**", handler: "docs.ts" },
+ ],
+ prerenderedRoutes: [
+ { route: "/blog/post", fileName: "/blog/post/index.html" },
+ { route: "/docs/nested/page", fileName: "/docs/nested/page/index.html" },
+ ],
+ })
+ )
+ ).toEqual(["blog/[slug]", "docs/[...]"]);
+ });
+
+ it("keeps routes whose prerendered file was not written", () => {
+ expect(
+ dests(
+ createNitroStub({
+ handlers: [{ route: "/failed", handler: "failed.ts" }],
+ prerenderedRoutes: [{ route: "/failed" }],
+ })
+ )
+ ).toEqual(["failed"]);
+ });
+
+ it("keeps routes without prerendering", () => {
+ expect(dests(createNitroStub({ handlers: [{ route: "/foo", handler: "foo.ts" }] }))).toEqual([
+ "foo",
+ ]);
+ });
+});
diff --git a/test/unit/vercel-overrides.test.ts b/test/unit/vercel-overrides.test.ts
new file mode 100644
index 0000000000..b42f0f8e1b
--- /dev/null
+++ b/test/unit/vercel-overrides.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { getPrerenderOverrides } from "../../src/presets/vercel/utils.ts";
+
+describe("getPrerenderOverrides", () => {
+ it("returns no overrides without prerendered routes", () => {
+ expect(getPrerenderOverrides()).toEqual({});
+ expect(getPrerenderOverrides([])).toEqual({});
+ });
+
+ it("skips files Vercel serves as directory indexes", () => {
+ expect(
+ getPrerenderOverrides([
+ { route: "/", fileName: "/index.html" },
+ { route: "/noslash", fileName: "/noslash/index.html" },
+ { route: "/nested/deep/", fileName: "/nested/deep/index.html" },
+ // Extensionless index, e.g. a non-HTML route with a trailing slash
+ { route: "/api/data/", fileName: "/api/data/index" },
+ ])
+ ).toEqual({});
+ });
+
+ // Keeping the trailing slash makes the path unmatchable (#4392)
+ it("does not emit a trailing-slash path", () => {
+ expect(getPrerenderOverrides([{ route: "/slash/", fileName: "/slash/index.html" }])).toEqual(
+ {}
+ );
+ expect(getPrerenderOverrides([{ route: "/slash/", fileName: "/renamed/index.html" }])).toEqual({
+ "renamed/index.html": { path: "slash" },
+ });
+ });
+
+ it("overrides files that are not served at their route", () => {
+ expect(
+ getPrerenderOverrides([
+ // `autoSubfolderIndex: false`
+ { route: "/about", fileName: "/about.html" },
+ { route: "/blog/post", fileName: "/blog/post.html" },
+ // `fileName` rewritten in a `prerender:generate` hook
+ { route: "/bar/", fileName: "/renamed/index.html" },
+ ])
+ ).toEqual({
+ "about.html": { path: "about" },
+ "blog/post.html": { path: "blog/post" },
+ "renamed/index.html": { path: "bar" },
+ });
+ });
+
+ // Vercel deletes the original entry when re-keying, so an override pointing a
+ // file at its own path would remove it from the deployment entirely
+ it("never points a file at its own path", () => {
+ const overrides = getPrerenderOverrides([
+ { route: "/foo.html", fileName: "/foo.html" },
+ { route: "/foo/index.html", fileName: "/foo/index.html" },
+ { route: "/data.json", fileName: "/data.json" },
+ // Route and file name normalize to the same key despite different spelling
+ { route: "/self/", fileName: "/self" },
+ { route: "/about", fileName: "/about.html" },
+ ]);
+ for (const [file, { path }] of Object.entries(overrides)) {
+ expect(path).not.toBe(file);
+ }
+ expect(overrides).toEqual({ "about.html": { path: "about" } });
+ });
+
+ it("ignores routes without a fileName", () => {
+ expect(getPrerenderOverrides([{ route: "/skipped" }])).toEqual({});
+ });
+});