Summary
vinext serves App Router metadata files (app/icon.*, app/apple-icon.*, app/opengraph-image.*, app/twitter-image.*) at extensionless URLs (/icon, /apple-icon, etc.), whereas Next.js serves them at URLs that preserve the source extension (/icon.png, /apple-icon.png, …) with a cache-busting query string.
This difference silently breaks any project using the common Next.js middleware matcher pattern, because that pattern excludes requests by file extension. vinext's extensionless URLs fall through to protected-route logic and get redirected to /login (or equivalent), so icons never render.
Next.js behavior (for reference)
Build output of a real Next.js 16 project with app/icon.png, app/apple-icon.png, app/favicon.ico:
.next/server/app/apple-icon.png
.next/server/app/apple-icon.png.body
.next/server/app/apple-icon.png.meta
.next/server/app/favicon.ico
.next/server/app/icon.png
.next/server/app/icon.png.body
.next/server/app/icon.png.meta
routes-manifest.json:
{ "page": "/apple-icon.png", "regex": "^/apple\\-icon\\.png(?:/)?$", ... }
{ "page": "/favicon.ico", "regex": "^/favicon\\.ico(?:/)?$", ... }
{ "page": "/icon.png", "regex": "^/icon\\.png(?:/)?$", ... }
Rendered HTML <head>:
<link rel="icon" href="/favicon.ico?04805bf34170f8dc" type="image/x-icon" sizes="16x16"/>
<link rel="icon" href="/icon.png?83dcf9c52257c0da" type="image/png" sizes="512x512"/>
<link rel="apple-touch-icon" href="/apple-icon.png?18aafdc0905dc23c" type="image/png" sizes="180x180"/>
Two important properties:
- Extension is preserved in the URL (
/icon.png, not /icon).
- A per-file cache-busting hash is appended as a query string.
vinext behavior (current)
dist/server/metadata-routes.js:
const METADATA_FILE_MAP = {
favicon: { urlPath: "/favicon.ico", ... },
icon: { urlPath: "/icon", ... }, // ← no extension
"apple-icon": { urlPath: "/apple-icon", ... }, // ← no extension
"opengraph-image":{ urlPath: "/opengraph-image", ... }, // ← no extension
"twitter-image": { urlPath: "/twitter-image", ... }, // ← no extension
};
With app/icon.svg in a project, the SSR <head> contains:
<link rel="icon" href="http://localhost:5100/icon" sizes="any" type="image/svg+xml"/>
And requesting /icon directly:
GET /icon → 200 OK (serves the SVG)
No hash, no extension.
Why this breaks real projects
The middleware matcher pattern recommended by Next.js docs (and used verbatim in many projects, including minform and kitful) excludes assets by file extension:
export const config = {
matcher: [
{
source:
'/((?!_next|[^.]*\\.(?:png|webp|avif|svg|jpeg|jpg|ico|gif|pdf|txt|json|xml|htm|html|css|scss|js|woff2|woff|eot|ttf|otf|ico|webmanifest|mp4)).*)',
},
],
};
Under this matcher:
- Next.js:
/icon.png?<hash> matches [^.]*\.png, so middleware is skipped — icon loads.
- vinext:
/icon has no extension, so the matcher runs middleware — auth redirect fires — icon request gets a 307 to /login?redirectURI=%2Ficon — browser receives HTML instead of an image — Firefox/Safari fail to render the icon (Chrome masks it by falling back to root /favicon.ico auto-discovery, so this bug can look deceptively browser-specific).
This is the root cause of https://github.com/cloudflare/vinext/issues/… (if a related "favicon not loading" report exists).
Reproduction
- Fresh vinext App Router project.
- Drop
app/icon.svg (or icon.png) and app/favicon.ico into app/.
- Add a minimal auth middleware that redirects unauthenticated requests to
/login, using the common matcher above. (Or just any middleware.ts whose matcher doesn't explicitly exclude /icon.)
- Load any page and inspect the
<head> — icon <link> points to /icon (no extension).
curl -I http://localhost:<port>/icon → 307 to /login.
- Open the page in Firefox — the tab shows no favicon.
Expected: /icon.svg?<hash> (or /icon.png?<hash>), matching Next.js.
Suggested fix
In src/server/metadata-routes.ts:
- Change
urlPath in METADATA_FILE_MAP to be a template (or computed from the discovered file's extension). For the icon, apple-icon, opengraph-image, twitter-image types, emit /<basename>.<ext> rather than /<basename>.
- For
favicon, keep /favicon.ico (already matches Next.js).
- Generate a per-file cache-bust hash (e.g. FNV-1a of the file bytes, first 16 hex chars) and append as a query string in the
<link> tag to match Next.js behavior.
- When multiple source extensions are registered for the same base name (e.g., both
icon.png and icon.svg), emit one route per extension and one <link> tag per file — same as Next.js.
Impact
Any project using the default-style Next.js middleware matcher (i.e., most real projects with authentication) will see:
- Icons not rendering in Firefox/Safari.
- In Chrome, silent fallback to root
/favicon.ico auto-discovery — works by accident but hides the bug during development.
- Extra overhead on the auth middleware for every icon request.
- Breakage of
apple-icon, opengraph-image, and twitter-image for social previews (crawlers don't do Chrome-style /favicon.ico fallback).
Workaround
Either:
- Add
icon|apple-icon|opengraph-image|twitter-image to the middleware matcher exclusion list, or
- Define
metadata.icons explicitly in app/layout.tsx and rely on the browser's root /favicon.ico auto-discovery for the tab favicon.
Both feel like papering over a behavior gap rather than a real fix.
Summary
vinext serves App Router metadata files (
app/icon.*,app/apple-icon.*,app/opengraph-image.*,app/twitter-image.*) at extensionless URLs (/icon,/apple-icon, etc.), whereas Next.js serves them at URLs that preserve the source extension (/icon.png,/apple-icon.png, …) with a cache-busting query string.This difference silently breaks any project using the common Next.js middleware matcher pattern, because that pattern excludes requests by file extension. vinext's extensionless URLs fall through to protected-route logic and get redirected to
/login(or equivalent), so icons never render.Next.js behavior (for reference)
Build output of a real Next.js 16 project with
app/icon.png,app/apple-icon.png,app/favicon.ico:routes-manifest.json:{ "page": "/apple-icon.png", "regex": "^/apple\\-icon\\.png(?:/)?$", ... } { "page": "/favicon.ico", "regex": "^/favicon\\.ico(?:/)?$", ... } { "page": "/icon.png", "regex": "^/icon\\.png(?:/)?$", ... }Rendered HTML
<head>:Two important properties:
/icon.png, not/icon).vinext behavior (current)
dist/server/metadata-routes.js:With
app/icon.svgin a project, the SSR<head>contains:And requesting
/icondirectly:No hash, no extension.
Why this breaks real projects
The middleware matcher pattern recommended by Next.js docs (and used verbatim in many projects, including minform and kitful) excludes assets by file extension:
Under this matcher:
/icon.png?<hash>matches[^.]*\.png, so middleware is skipped — icon loads./iconhas no extension, so the matcher runs middleware — auth redirect fires — icon request gets a 307 to/login?redirectURI=%2Ficon— browser receives HTML instead of an image — Firefox/Safari fail to render the icon (Chrome masks it by falling back to root/favicon.icoauto-discovery, so this bug can look deceptively browser-specific).This is the root cause of https://github.com/cloudflare/vinext/issues/… (if a related "favicon not loading" report exists).
Reproduction
app/icon.svg(oricon.png) andapp/favicon.icointoapp/./login, using the common matcher above. (Or just anymiddleware.tswhose matcher doesn't explicitly exclude/icon.)<head>— icon<link>points to/icon(no extension).curl -I http://localhost:<port>/icon→ 307 to/login.Expected:
/icon.svg?<hash>(or/icon.png?<hash>), matching Next.js.Suggested fix
In
src/server/metadata-routes.ts:urlPathinMETADATA_FILE_MAPto be a template (or computed from the discovered file's extension). For theicon,apple-icon,opengraph-image,twitter-imagetypes, emit/<basename>.<ext>rather than/<basename>.favicon, keep/favicon.ico(already matches Next.js).<link>tag to match Next.js behavior.icon.pngandicon.svg), emit one route per extension and one<link>tag per file — same as Next.js.Impact
Any project using the default-style Next.js middleware matcher (i.e., most real projects with authentication) will see:
/favicon.icoauto-discovery — works by accident but hides the bug during development.apple-icon,opengraph-image, andtwitter-imagefor social previews (crawlers don't do Chrome-style/favicon.icofallback).Workaround
Either:
icon|apple-icon|opengraph-image|twitter-imageto the middleware matcher exclusion list, ormetadata.iconsexplicitly inapp/layout.tsxand rely on the browser's root/favicon.icoauto-discovery for the tab favicon.Both feel like papering over a behavior gap rather than a real fix.