[GH-ISSUE #884] Metadata file routes (icon, apple-icon, opengraph-image, twitter-image) are served at the wrong URL — extension is stripped #197

Closed
opened 2026-05-06 12:38:04 +02:00 by BreizhHardware · 0 comments

Originally created by @eashish93 on GitHub (Apr 24, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/884

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:

  1. Extension is preserved in the URL (/icon.png, not /icon).
  2. 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

  1. Fresh vinext App Router project.
  2. Drop app/icon.svg (or icon.png) and app/favicon.ico into app/.
  3. 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.)
  4. Load any page and inspect the <head> — icon <link> points to /icon (no extension).
  5. curl -I http://localhost:<port>/icon → 307 to /login.
  6. 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:

  1. 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>.
  2. For favicon, keep /favicon.ico (already matches Next.js).
  3. 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.
  4. 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.

Originally created by @eashish93 on GitHub (Apr 24, 2026). Original GitHub issue: https://github.com/cloudflare/vinext/issues/884 ## 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`: ```json { "page": "/apple-icon.png", "regex": "^/apple\\-icon\\.png(?:/)?$", ... } { "page": "/favicon.ico", "regex": "^/favicon\\.ico(?:/)?$", ... } { "page": "/icon.png", "regex": "^/icon\\.png(?:/)?$", ... } ``` Rendered HTML `<head>`: ```html <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: 1. **Extension is preserved** in the URL (`/icon.png`, not `/icon`). 2. A per-file cache-busting hash is appended as a query string. ## vinext behavior (current) `dist/server/metadata-routes.js`: ```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: ```html <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: ```ts 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 1. Fresh vinext App Router project. 2. Drop `app/icon.svg` (or `icon.png`) and `app/favicon.ico` into `app/`. 3. 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`.) 4. Load any page and inspect the `<head>` — icon `<link>` points to `/icon` (no extension). 5. `curl -I http://localhost:<port>/icon` → 307 to `/login`. 6. 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`: 1. 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>`. 2. For `favicon`, keep `/favicon.ico` (already matches Next.js). 3. 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. 4. 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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#197
No description provided.