Collection permalinks containing .html return 404 on Ghost 6 (worked on Ghost 5)
Summary
A routes.yaml collection permalink that includes a .html suffix (common on sites migrated from WordPress or other platforms that preserved legacy URLs) returns a plain-text File not found 404 on Ghost 6. The same routes file works correctly on Ghost 5.
Ghost still generates these URLs everywhere (post cards, sitemap, canonical tags), so the site links to URLs that Ghost itself then refuses to serve.
Environment
- Ghost version: 6.57.1 (the relevant middleware is unchanged on current
main)
- Worked on: Ghost 5.x (production site currently live on 5.x with this exact routes file)
- Node: v22.23.1
- Database: sqlite3 (dev) / MySQL (production) — not database-related
- Install: ghost-cli local install
Steps to reproduce
-
Upload this routes.yaml:
collections:
/:
permalink: /{primary_tag}/{slug}.html/
template: index
taxonomies:
tag: /category/{slug}/
author: /author/{slug}/
-
Publish a post with a primary tag, e.g. slug my-post with primary tag finance.
-
Visit the homepage: the post card correctly links to /finance/my-post.html/.
-
Visit /finance/my-post.html/ (or /finance/my-post.html without the trailing slash).
Expected: the post renders (Ghost 5 behavior).
Actual: HTTP 404 with the plain-text body File not found.
Root cause
core/frontend/web/middleware/static-theme.js sends every request path that has a file extension to express.static with fallthrough set to false unless the path is in the hard-coded isFallthroughFile allowlist (robots.txt, sitemaps, llms.txt):
function forwardToExpressStatic(req, res, next, options = {}) {
...
const fallthrough = isFallthroughFile(req.path);
express.static(themeEngine.getActive().path, Object.assign({
maxAge: config.get('caching:theme:maxAge') * 1000,
fallthrough
}, options))(req, res, next);
}
path.extname('/finance/my-post.html/') returns .html (trailing slash is stripped by path.basename), so the request is classified as a static file request. .html is not in DENIED_FILE_TYPES and the file does not exist in the theme directory, so with fallthrough: false the request dies here with a 404 and never reaches the dynamic collection router.
The 404 is then rendered as plain text by core/frontend/web/middleware/error-handler.js, which treats any extension-bearing 404 as a static-file error (hasExtension check in themeErrorRenderer).
On Ghost 5 the static miss fell through to the dynamic router, so the collection route (mounted as /:primary_tag/:slug.html/) matched and served the post.
Note the internal inconsistency: route-settings/validate.js accepts the permalink, the URL service happily generates /{primary_tag}/{slug}.html/ URLs for every post (homepage, sitemap, canonical), and the collection router mounts a matching Express route — but the static-theme middleware blocks every request to those URLs before routing runs.
Workaround
Adding .html to DENIED_FILE_TYPES in static-theme.js restores Ghost 5 behavior (denied extensions skip static serving and fall through to the dynamic router), at the cost of not being able to serve raw .html files from the theme root. This obviously does not survive ghost update.
Suggested fix directions
- Only use
fallthrough: false for paths that plausibly target theme static files (e.g. under /assets/, or extensions that exist in the theme), and fall through otherwise; or
- Check registered collection permalinks for extension-bearing patterns and skip the static short-circuit for matching requests; or
- At minimum, reject
.html (and other extension) permalinks in route-settings/validate.js so the misconfiguration fails loudly at upload time instead of silently 404ing every post.
Happy to provide more detail or test a fix.
Collection permalinks containing
.htmlreturn 404 on Ghost 6 (worked on Ghost 5)Summary
A
routes.yamlcollection permalink that includes a.htmlsuffix (common on sites migrated from WordPress or other platforms that preserved legacy URLs) returns a plain-textFile not found404 on Ghost 6. The same routes file works correctly on Ghost 5.Ghost still generates these URLs everywhere (post cards, sitemap, canonical tags), so the site links to URLs that Ghost itself then refuses to serve.
Environment
main)Steps to reproduce
Upload this
routes.yaml:Publish a post with a primary tag, e.g. slug
my-postwith primary tagfinance.Visit the homepage: the post card correctly links to
/finance/my-post.html/.Visit
/finance/my-post.html/(or/finance/my-post.htmlwithout the trailing slash).Expected: the post renders (Ghost 5 behavior).
Actual: HTTP 404 with the plain-text body
File not found.Root cause
core/frontend/web/middleware/static-theme.jssends every request path that has a file extension toexpress.staticwithfallthroughset tofalseunless the path is in the hard-codedisFallthroughFileallowlist (robots.txt, sitemaps, llms.txt):path.extname('/finance/my-post.html/')returns.html(trailing slash is stripped bypath.basename), so the request is classified as a static file request..htmlis not inDENIED_FILE_TYPESand the file does not exist in the theme directory, so withfallthrough: falsethe request dies here with a 404 and never reaches the dynamic collection router.The 404 is then rendered as plain text by
core/frontend/web/middleware/error-handler.js, which treats any extension-bearing 404 as a static-file error (hasExtensioncheck inthemeErrorRenderer).On Ghost 5 the static miss fell through to the dynamic router, so the collection route (mounted as
/:primary_tag/:slug.html/) matched and served the post.Note the internal inconsistency:
route-settings/validate.jsaccepts the permalink, the URL service happily generates/{primary_tag}/{slug}.html/URLs for every post (homepage, sitemap, canonical), and the collection router mounts a matching Express route — but the static-theme middleware blocks every request to those URLs before routing runs.Workaround
Adding
.htmltoDENIED_FILE_TYPESinstatic-theme.jsrestores Ghost 5 behavior (denied extensions skip static serving and fall through to the dynamic router), at the cost of not being able to serve raw.htmlfiles from the theme root. This obviously does not surviveghost update.Suggested fix directions
fallthrough: falsefor paths that plausibly target theme static files (e.g. under/assets/, or extensions that exist in the theme), and fall through otherwise; or.html(and other extension) permalinks inroute-settings/validate.jsso the misconfiguration fails loudly at upload time instead of silently 404ing every post.Happy to provide more detail or test a fix.