Skip to content

.md URLs 404 or redirect infinitely for pages remapped by routes.yaml #30375

Description

@mremond

Summary

When a page is given a URL by routes.yaml — as a collection's data: source, or as the site index — its .md variant stops resolving. Ghost issues a 301 to a path composed of the page's routed directory plus the page's slug, which does not exist:

/essais.md  →  301  →  /essais/essais.md  →  404

When the page is served at /, the 301 points at the request URL itself, producing an infinite redirect loop.

llms.txt, which Ghost generates, lists these URLs, so the advertised agent-discovery surface contains dead links and at least one redirect loop.

Environment

  • Reproduced on a clean ghost:6-alpine container, Ghost 6.60.0, SQLite, default Casper theme, no theme changes.
  • Same symptoms observed on Ghost 6.61 (Ghost Pro) on a live site.

Steps to reproduce

Fresh install, then create one published page with slug essais.

Control — default routes

GET /essais/     → 200 text/html
GET /essais.md   → 200 text/markdown        ✅

Case A — page used as a collection's data: source

routes:

collections:
  /essais/:
    permalink: /essais/{slug}/
    template: index
    data: page.essais

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/
GET /essais/          → 200 text/html
GET /essais.md        → 301 → /essais/essais.md
GET /essais/essais.md → 404                  ❌

Case B — same, with no name collision

Collection mounted at /rubrique/, page slug still essais:

collections:
  /rubrique/:
    permalink: /rubrique/{slug}/
    template: index
    data: page.essais
GET /essais/            → 301 → /rubrique/   (correct HTML redirect)
GET /rubrique/          → 200 text/html
GET /essais.md          → 301 → /rubrique/essais.md
GET /rubrique/essais.md → 404                ❌
GET /rubrique.md        → 404                ❌  (no markdown URL exists at all)

The collection path and the page slug being the same word is not required — any page whose URL comes from routes.yaml is affected.

Case C — page served at the site root

routes:
  /:
    data: page.accueil
    template: index
GET /             → 200 text/html   (serves the page body)
GET /accueil/     → 301 → /         → 200 text/html   ✅ HTML handled correctly
GET /accueil      → 301 → /accueil/ → 301 → / → 200   ✅

GET /accueil.md   → 301 → /accueil.md → 301 → …   ❌ infinite loop
                    (still redirecting after 10 hops)
GET /index.md     → 404                            ❌

/index.md is the path Ghost's own getMarkdownPath() returns for /, so on this configuration there is no working markdown URL for the home page at all. llms.txt on this same instance advertises /accueil.md — the looping URL.

This is not the .html static-asset behaviour from #29991

#29991 was closed with "Extension-bearing URLs are treated as theme static files by design." That reasoning does not apply here, and the response codes prove it:

GET /does-not-exist.md   → 404      static fallthrough, as described in #29991
GET /a-propos.md         → 200      markdown route, page at its natural URL
GET /tech.md             → 301      markdown route engaged, wrong target computed

A URL falling through to the static handler returns 404. /tech.md returns a 301, which means Ghost's markdown router resolved the page and then built a redirect target that does not exist. .md is a first-class Ghost route, not a theme asset: it is served by core/frontend/services/routing/controllers/entry/markdown.js, it works for every post and for pages at their natural URL, and Ghost itself generates these URLs in llms.txt.

This is not a configuration mistake either

data: on a collection is a supported routes.yaml feature, and Ghost handles the resulting URL change correctly for HTML. Only the markdown path fails. Same page, same routes.yaml, same Ghost-issued redirect:

HTML      /essais/    → 301 → /rubrique/           → 200 text/html      ✅
MARKDOWN  /essais.md  → 301 → /rubrique/essais.md  → 404                ❌

Ghost knows the page now lives at /rubrique/ — its HTML redirect proves it. Case C shows the identical split on a root-mounted page.

Expected

For a page served at /rubrique/, the markdown variant should be /rubrique.md (and /index.md for a page served at /), matching what core/frontend/services/llms/markdown.jsgetMarkdownPath() already computes from a pathname. Alternatively /{slug}.md should keep serving the page directly.

At minimum, the root case should not redirect to itself.

Actual

The redirect target appears to be composed as <routed directory> + <slug> + .md rather than by passing the page's canonical pathname through getMarkdownPath().

Pointer rather than a diagnosis — I did not trace the full path: core/frontend/services/routing/controllers/entry/markdown.js and core/frontend/services/llms/markdown.js (getMarkdownPath, getMarkdownUrl).

Impact on a live site

On a production site (Ghost 6.61) using routes.yaml, checking every URL its own llms.txt advertises:

Resolve Broken
Posts 205 / 205 0
Pages 6 / 14 8

The eight failures are the site's main entry points, and each is a page remapped by routes.yaml:

/tech.md      → 301 → /tech/tech.md          → 404
/fiction.md   → 301 → /fiction/fiction.md    → 404
/archives.md  → 301 → /archives/archives.md  → 404
/le-flow.md   → 301 → /le-flow/le-flow.md    → 404
/podcast.md   → 301 → /double-vie/podcast.md → 404
/en-blog.md   → 301 → /en/blog/en-blog.md    → 404
/home-en.md   → 301 → /en/home-en.md         → 404
/home.md      → 301 → /home.md               → infinite loop

Neither redirects.yaml nor the theme can work around this: no URL variant serves the content. /tech/.md returns 403, and /tech/index.md, /tech.md/ and /tech/tech.md all return 404.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs:triage[triage] this needs to be triaged by the Ghost team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions