diff --git a/src/build/vite/dev.ts b/src/build/vite/dev.ts
index ab4f24671b..7806c3a63f 100644
--- a/src/build/vite/dev.ts
+++ b/src/build/vite/dev.ts
@@ -245,10 +245,9 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
const fetchDest = req.headers["sec-fetch-dest"];
const accept = req.headers["accept"];
const ext = req.url!.split(/[?#]/, 1)[0].match(/\.([a-z0-9]+)$/i)?.[1];
- const isNitroRoute = !!nitro.routing.routes.match(
- req.method || "",
- new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
- );
+ const reqPathname = new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname;
+ const nitroRouteMatch = nitro.routing.routes.match(req.method || "", reqPathname);
+ const isNitroRoute = !!nitroRouteMatch;
// Sec-Fetch-* is only sent on "potentially trustworthy" origins, so on plain-HTTP non-loopback (e.g. http://10.0.0.x) it's absent and a splat Nitro route may swallow browser asset loads (#4234). When the header is missing, treat known asset extensions without `text/html` in Accept as asset loads and let Vite handle them.
const isAssetByDest =
typeof fetchDest === "string" && !/^(document|iframe|frame|empty)$/.test(fetchDest);
@@ -256,8 +255,18 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
const acceptsHTML = typeof accept === "string" && /\btext\/html\b/.test(accept);
const treatAsAsset = isAssetByDest || (!fetchDest && isAssetByExt && !acceptsHTML);
res.setHeader("vary", "sec-fetch-dest, accept");
- // An explicit Nitro route reaches Nitro even when the request is tagged as an asset (e.g. `
` with `sec-fetch-dest: image`, #4241), UNLESS the URL also has an asset-like extension — in that case Vite stays the definitive handler so a splat doesn't swallow `