Skip to content

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

@eashish93

Description

@eashish93

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions