Multilingual Ghost on one install: routes, tags and hreflang
Ghost's own documentation recommends one install per language when the content differs. That works, but it means a second server, a proxy and two admin panels. If you would rather keep one install, you can: give each language its own path with routes.yaml, mark each post's language with an internal tag, link the translations with a shared tag, and have the theme print the hreflang links. This guide shows each piece, with the snippets and the mistakes that cost an afternoon when we tested them on Ghost 6.70.
The shape of the solution
- The default language stays at
/and has no language tag, so nothing you already published moves. - Every other language lives under a path such as
/es/and is selected by an internal tag such as#es. Internal tags start with#and never appear to readers. - All translations of one article carry the same pair tag, for example
#pair-launch. A theme reads it to find the other versions.
Step 1: routes.yaml
For English plus Spanish the file looks like this. Upload it under Settings, Labs, Routes (it replaces your current routes). Internal tag #es has the slug hash-es, and that is what the filter uses.
routes:
collections:
/es/:
permalink: /es/{slug}/
template: index
filter: tag:hash-es
/:
permalink: /{slug}/
template: index
filter: tag:-[hash-es]
taxonomies:
tag: /tag/{slug}/
author: /author/{slug}/
Each collection gets its own home page, pagination and RSS feed: /rss/ carries only the default language and /es/rss/ only Spanish. The routes generator writes this file for any number of languages.
Step 2: tag the content
Spanish posts get #es and a slug of their own. Every language version of one article gets the same pair tag. Pages (About, Contact) work the same way, with one catch: Ghost always serves pages at /slug/, so a Spanish About page is at /acerca-de/, not /es/acerca-de/.
Step 3: hreflang in the theme
Google needs every version of a page to list itself and all the others, with absolute URLs, an x-default, and the same set on every version. This partial does that for English plus Spanish. Save it as partials/polyglot/hreflang.hbs, add the second partial shown below it, then call {{> "polyglot/hreflang"}} in default.hbs just before {{ghost_head}}.
{{#match (url) "/"}}
<link rel="alternate" hreflang="en" href="{{@site.url}}/">
<link rel="alternate" hreflang="es" href="{{@site.url}}/es/">
<link rel="alternate" hreflang="x-default" href="{{@site.url}}/">
{{else match (url) "/es/"}}
<link rel="alternate" hreflang="en" href="{{@site.url}}/">
<link rel="alternate" hreflang="es" href="{{@site.url}}/es/">
<link rel="alternate" hreflang="x-default" href="{{@site.url}}/">
{{/match}}
{{#is "post, page"}}{{#with post as |self|}}
<link rel="alternate" hreflang="{{> polyglot/code-of}}" href="{{url absolute="true"}}">
{{#has tag="#es"}}{{else}}<link rel="alternate" hreflang="x-default" href="{{url absolute="true"}}">{{/has}}
{{#foreach tags visibility="all"}}{{#match slug "~^" "hash-pair-"}}
{{#get "posts" filter="tag:{{slug}}" include="tags" limit="100"}}{{#foreach posts}}{{#match url "!=" self.url}}<link rel="alternate" hreflang="{{> polyglot/code-of}}" href="{{url absolute="true"}}">{{#has tag="#es"}}{{else}}<link rel="alternate" hreflang="x-default" href="{{url absolute="true"}}">{{/has}}{{/match}}{{/foreach}}{{/get}}
{{#get "pages" filter="tag:{{slug}}" include="tags" limit="100"}}{{#foreach pages}}{{#match url "!=" self.url}}<link rel="alternate" hreflang="{{> polyglot/code-of}}" href="{{url absolute="true"}}">{{#has tag="#es"}}{{else}}<link rel="alternate" hreflang="x-default" href="{{url absolute="true"}}">{{/has}}{{/match}}{{/foreach}}{{/get}}
{{/match}}{{/foreach}}
{{/with}}{{/is}}
It needs one more partial, partials/polyglot/code-of.hbs, which returns a post's language code. It must be a single line with no trailing newline, because it is used inside an attribute:
{{#has tag="#es"}}es{{else}}en{{/has}}
Check the result on a live page with node bin/ghost-polyglot-routes.mjs verify https://your-site.com/some-post/ from the open-source tool (on GitHub, coming soon): it fetches the page, then every alternate, and tells you if one does not link back.
Mistakes we hit
- The
matchhelper's operator is a separate argument.{{#match slug "~^" "hash-pair-"}}means "starts with". Writing"~^hash-pair-"as one string silently matches nothing. {{#with post}}changes the context. Inside it, a partial parameter such askis gone. Re-call partials with..to pass the parent context back.@rootis rejected by gscan, Ghost's theme validator, as an unknown global. Use(url)and{{#with post}}instead, and runnpx gscan your-themebefore you upload.- Do not use
limit="all"in{{#get}}: gscan warns about it.limit="100"is plenty for a group of translations. - Unconfigured tags break the set. A post tagged
#frin a pair with English and Spanish posts, when only English and Spanish are configured, produces twoenalternates pointing at different URLs. The verifier flags it as an error. {{#get}}does not know about your collections. Any{{#get "posts"}}on the home page (a featured list, "latest" blocks) lists every language unless you add the sametag:-[hash-es]filter.
What you still will not get
Ghost has one language setting. Its sign-up pop-up, comments, emails and search stay in that language, and the built-in tag and author archives list posts from every language. The theme's own buttons can be translated with a small partial per language. Everything this guide describes is what the theme controls.
Skip the wiring
Writing the switcher, per-language menus, translated theme strings and the three- or five-language versions of these partials by hand is the slow part. Polyglot for Ghost generates all of it from one config file and ships a patched Source theme that passes gscan with no errors or warnings; the live demo shows it running with three languages.