> ## Content Index
> Fetch the complete content index at: https://konogaijin.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# I made Ghost work with multiple languages — and I wanted to share
- URL: https://konogaijin.com/en-us/articles/i-made-ghost-work-with-multiple-languages/
- Published: 2026-09-20T03:15:27.000Z
- Updated: 2026-09-20T04:21:36.000Z
- Description: Hi! I made this blog using Ghost, which is a very nice blog/newsletter tool/platform, but it doesn't support multi-language very well, well, not in the way I would like it.
- Author: Kit
- Tags: #lang-en-us, #translation-translation-architecture, Architecture

Hi! I made this blog using Ghost, which is a very nice blog/newsletter tool/platform, but it doesn't support multi-language very well, well, not in the way I would like it.

A lot of people struggle with a multi-language Ghost blog, because Ghost simply does not support it very well. It can translate a theme, but there is no real tooling for multi-language articles or newsletters. I *realllllly* wanted to use Ghost (because of newsletters and membership systems), so I took a stab at making it i18n (multi-language friendly). This post is how I did it. I will not write every line of production code — that would be way too boring. I will explain the gist, show the pieces you actually have to invent, and leave a [minimal sample repo](https://github.com/JoaoVictorLouro/ghost-i18n-sample?ref=konogaijin.com) you can `docker compose up`.

If you are in a hurry, just feed this into your favorite flavored LLM of choice and ask it do copy the setup, it will likely do just fine (but I take no responsibility!). Whatever you decided to do, if this helps you, leave some kudos, exposure and maybe pay me a coffee for saving you trouble!

PS: This is for people who **self-host** Ghost. If you are on Ghost(Pro), you can keep reading, but you will have to host the extra bits yourself. If you do not want that, you can stop here.

PS again: I will talk about how templates *behave*. I will not teach you how to build a theme from scratch. For that, read [Default template explained by the Ghost team](https://ghost.org/tutorials/default/?ref=konogaijin.com) and [Write your own Ghost theme by Mat Duggan](https://matduggan.com/write-your-own-ghost-theme/?ref=konogaijin.com).

---

Ghost speaks one language at a time. This site speaks four — English (US), Japanese, Brazilian Portuguese, and Latin American Spanish — without pretending Ghost grew a translation module. The trick is not a single “i18n plugin.” It is three small systems that stay out of each other’s way: the words around the article (nav, buttons, empty states), the articles themselves (one Ghost post per language, linked by tags), and the email lists (one newsletter per locale).

If you only remember one picture, remember this: **templates with a compiled helper, posts with language tags, a newsletter for each language.** They share a locale list.

## Index

1. [Why Ghost alone is not enough](#why-ghost-alone-is-not-enough)
2. [The locale list everything else copies](#the-locale-list-everything-else-copies)
3. [The three layers](#the-three-layers)
4. [Layer 1 — translating the template](#layer-1--translating-the-template)
5. [Layer 2 — translating the article](#layer-2--translating-the-article)
6. [A tiny Python server for the map](#a-tiny-python-server-for-the-map)
7. [Layer 3 — wiring the newsletter form](#layer-3--wiring-the-newsletter-form)
8. [A walkthrough: publishing this piece in four languages](#a-walkthrough-publishing-this-piece-in-four-languages)
9. [Checklist](#checklist)

## Why Ghost alone is not enough

Ghost can translate the template — sort of. The built-in `{{t}}` helper follows **the site language in Admin Settings**. Flip the site to Japanese and every URL tries to speak Japanese. I did not want that. I wanted each language scoped to its own path: `/en-us`, `/pt-br`, `/ja-jp`, and so on. That makes the language obvious from the URL (great for SEO and sharing) and makes redirects and detection boring in a good way.

Ghost also has no “this post is a translation of that post” field. A post is a post. If you want a Japanese version, you create a second post. If you want those two posts to find each other in a language picker, you invent the link.

So the stack answers three separate questions:

- How does the **template** know it is on `/pt-br/`?
- How does an **article** land on the right index, and how do sibling posts find each other?
- How does the **subscribe form** put someone on the Portuguese list instead of the English one?

## The locale list everything else copies

Start here, even before you touch a template. If the languages live in five different places, you will spend the rest of your life hunting typos. I wanted **one list** that the theme, the article routes, and the translation map all copy from. Add a language in that list, and everything else should be able to follow. Hard-code `"japanese"` in a random Handlebars file, and you will forget it exists.

### Pick the languages, then pick a default

Write down who you are actually going to write for. Not “every language someday” — the ones you can maintain. For me that was English (US), Japanese, Brazilian Portuguese, and Latin American Spanish. Then pick **one default**. That locale is the home for posts that somehow shipped without a language tag, and it is where `/` should send people if the browser does not help. Mine is English.

You do not need four. Two is already a real multilingual site. You can have as many languages as you want. In fact this setup doesn't work only for languages, you could do the same thing for regions if you really wanted.

### Give each language three names

This is the boring part, and it is where people get stuck. Each locale needs three identifiers, because the URL, the browser, and Ghost do not speak the same dialect.

1. **A URL code.** Lowercase, hyphenated, boring: `en-us`, `pt-br`, `ja-jp`. This is the first segment of the path (`/pt-br/articles/…`). Keep it stable. You will paste it into routes, sitemaps, and your own brain.
2. **A BCP-47 tag.** That is what goes on `<html lang="…">` and `hreflang`. Usually it looks like `en-US` or `pt-BR`. Latin American Spanish is the awkward one: there is no single country, so `es-419` is the honest code even if your URL says `es-la`. Here is the list of all BCP tags: [Language tags on Wikipedia](https://en.wikipedia.org/wiki/IETF%5Flanguage%5Ftag?ref=konogaijin.com).
3. **A Ghost internal tag.** Ghost has no locale field on a post, so we fake one with a tag. I use `#lang-en-us`, `#lang-pt-br`, and so on. Mark them **internal** in Ghost so they do not show up as cute chips on the article. In the editor you type the `#` name. In routes and filters you will see Ghost’s slug instead: `#lang-pt-br` becomes `hash-lang-pt-br`. Same tag, two spellings. Once you know that, the rest of Ghost’s filter syntax stops looking haunted.

You can keep extra labels on the list too — a long English name for settings screens, a native name (`Português`, `日本語`) for the language picker, etc.

A tiny example of one row in that list:

```js
{
  code: "pt-br",        // URL: /pt-br/
  bcp47: "pt-BR",       // <html lang="pt-BR">
  tagName: "#lang-pt-br",
  tagSlug: "hash-lang-pt-br",
  label: "Brazilian Portuguese",
  nativeLabel: "Português",
}

```

Repeat that object for every language, plus a `DEFAULT_LOCALE` pointing at your default code. Put it in a module, a JSON file, a YAML file — whatever your stack already works with. The format is not the point. The point is that **nothing else invents a new spelling**.

### Create the tags in Ghost once

Open Ghost Admin → Tags, and create those `#lang-*` tags as internal tags. Do it by hand if you only have two languages.

While you are there, decide how translations of the **same story** find each other. I use a second internal tag, `#translation-{some-stable-name}`. The stable name is just a slug you choose: `how-i-learned-to-stop-worrying`, or `translation-architecture`. Every language version of that story gets the **same** group tag, plus its own `#lang-*`. Language says “this post is Portuguese.” The group tag says “this post is the Portuguese sibling of that English one.”

You do not need the group tag on day one. You need it the moment you want a “read this in another language” link. If you do not want that widget, skip it.

### Create the newsletter groups

Ghost already knows how to email people. It does **not** know that `/pt-br/` should join a Portuguese list. Out of the box you get one newsletter. That is fine for a single-language blog. For this setup, you want **one newsletter per language**, named so you can still recognize them at 1 a.m.

Open Ghost Admin → Settings → Newsletters and create a list for every row in your locale table. I named mine after the URL code, loud and obvious: `Newsletter EN-US`, `Newsletter PT-BR`, and so on. The name is for you. Ghost will also hand you an id — a long weird string. You will need that id later for the subscribe form. Keep a small map next to your locale list:

```js
{
  "en-us": "the-ghost-id-for-english",
  "pt-br": "the-ghost-id-for-portuguese",
}

```

Copy those ids into the map after you create the lists. Same idea as the tags: do it once, then stop inventing names.

A few habits that save pain:

- **Stable names.** If you rename `Newsletter PT-BR` to `Novidades` next year, keep a key in *your* map that still says `pt-br`. The URL locale is the lookup, not the pretty title.
- **One language per send.** When you publish the Portuguese version of a post, attach the Portuguese newsletter. Attach the English list to that post and Portuguese subscribers get a language they did not sign up for. There are no guardrails. Double-check before you hit publish. You do read the stuff you post before you post it, right?

If you are not sending emails yet, skip this part. The tags and the locale list still work. Add the newsletters the week you actually want a send button. Just do not grow a fifth, undocumented list called “Newsletter” because Ghost offered you a default.

### Make the list the law

After that, the rule is social, not technical: if a new locale appears, it appears **in the list first**. Then you generate routes, theme strings, newsletters, and language tags from it. If you catch yourself typing `ja-jp` into a template because “it’s only one place,” that is how the fifth copy is born.

That is the whole setup. A short table of languages, three names each, tags in Ghost, one newsletter per locale, one default. Everything in the next sections is just that list showing up in the theme, in the article URLs, and in the subscribe form.

## The three layers

```mermaid
flowchart TB
  subgraph templates [Template i18n]
    json[locale JSON dictionaries]
    build[compile to Handlebars]
    partials[ui helper and nav]
    block[locale contentFor stamp]
    json --> build --> partials
    block --> partials
  end
  subgraph articles [Article translations]
    tags["hash-lang-* plus hash-translation-*"]
    collections[Ghost collections]
    mapBuild[sidecar builds JSON map]
    jsonMap["/i18n/article-translations.json"]
    ui[nav picker and post switcher]
    tags --> collections
    tags --> mapBuild --> jsonMap --> ui
  end
  subgraph newsletters [Locale newsletters]
    ghostNL[one Ghost newsletter per locale]
    nlJson[locale to newsletter id]
    form[Members subscribe form]
    ghostNL --> nlJson --> form
  end

```

Read it left to right, not as a pipeline. Changing the Subscribe button label never touches the article map. Publishing a Japanese draft never rewrites your English dictionary. Signing up for `Newsletter PT-BR` does not create a translation group.

Layer 1 is the theme, Layer 2 is the posts (plus a small server that prints the sibling map), Layer 3 is the subscribe form.

## Layer 1 — translating the template

### The simple version

The theme needs to say “Subscribe” in four languages. Ghost will not pick the string from the URL. So you keep one JSON dictionary per locale, compile them into Handlebars, and stamp the current locale onto each page before the layout renders.

Do not mix this up with Ghost’s own `locales/en.json` (or `pt-BR.json`, …). Those files feed `{{t}}`, which still follows the **site** language in Admin. Your dictionaries feed a **compiled** helper that follows the URL (or the post’s `#lang-*` tag). Two folders, two jobs.

### Stamping the locale

Ghost’s custom routes can point a URL at a **named template**. That is why each locale home is a tiny wrapper: `home-en-us.hbs` does not duplicate the homepage. It only says “this page is `en-us` / `en-US`” and then includes `home.hbs`. The articles index and about page do the same (`index-pt-br.hbs` → `index.hbs`). You need those wrappers because `routes.yaml`’s `template:` field is how Ghost picks the file — it will not infer locale from the path by itself.

That stamp is a Handlebars content block:

```handlebars
{{#contentFor 'locale'}}pt-br{{/contentFor}}
{{#contentFor 'bcp47'}}pt-BR{{/contentFor}}
{{!< index}}

```

Articles cannot use a per-locale template name that way — they all use `post.hbs`. Instead, a small partial walks the post’s internal tags. See `#lang-ja-jp`? The page becomes Japanese, even though the template file is the same.

The layout copies that into something the browser can read, and also peeks at the first path segment, so `<html lang="…">` matches `/ja-jp/…`.

Hitting `/` does not pick a language for the layout. A tiny detect page looks at `navigator.languages` and redirects to `/en-us/`, `/ja-jp/`, `/pt-br/`, or `/es-la/`. The real homes all live under a prefix.

### Where the words live

Editable copy lives in one JSON file per locale, next to the theme:

```
themes/your-theme/locales/en-us.json
themes/your-theme/locales/ja-jp.json
themes/your-theme/locales/pt-br.json
themes/your-theme/locales/es-la.json

```

A key looks like this in English:

```json
{
  "Subscribe": "SUBSCRIBE_TO_FEED",
  "No posts": "NO_SIGNAL"
}

```

The Portuguese file has the same keys, different values. Templates never hard-code those sentences. They ask for a key:

```handlebars
{{> "ui" key="Subscribe"}}

```

### Why we compile instead of looking up at runtime

You already met `{{t}}` in the previous section: it cannot follow `/pt-br/`. You could fetch JSON in the browser instead, but then the first paint would flash English, search engines would not see the Portuguese nav, and every template would grow a pile of JavaScript. Bad for SEO, a worse first load, and just plain ugly.

So you compile. A small build reads your dictionaries and writes Handlebars that already contains every language: on ordinary pages, match the locale block (`{{#match (block "locale") "pt-br"}}…`); on posts, match the `#lang-*` tag. The template still looks like `{{> "ui" key="Subscribe"}}`. Under the hood it is a generated `if / else if` tree.

That means every time you add new copy/text to the theme, you rebuild, then reload the theme in Ghost. I know its annoying, but you shouldn't be adding new text to the theme very often anyways. (it's not a post.)

Treat that generated partial as output, not as a file you craft. If you hand-edit the compiled helper, the next build will politely destroy your afternoon.

One practical detail: keep the compiled helper compact — no extra newlines inside the string. You will drop it into `aria-label` and `placeholder`, and Handlebars whitespace in attributes is a gift you do not want.

### Example: renaming a button

You want the English subscribe CTA to stay `OPEN_CHANNEL`, but Portuguese should read `ABRIR_CANAL`.

1. Edit the Portuguese locale file for the key the template already uses.
2. Run your compile step so the Handlebars helper picks up the new string.
3. Reload the theme in Ghost.

You did not touch Ghost posts. You did not touch newsletters. A reader on `/pt-br/` sees the new label; `/en-us/` does not.

## Layer 2 — translating the article

### The simple version

An article is not one entry with four bodies. It is **four Ghost posts** (or however many languages you actually wrote). A language tag puts each post on the right index URL. A shared translation tag tells the site they are the same story. A small JSON map, built from those tags, powers the “read in another language” links. That keeps each URL in one language, which search engines can actually rank.

### Language tags and URLs

Ghost collections (in `routes.yaml`) decide which posts appear under which path. A non-default locale is a straight tag filter. The **default** locale is the leftover bucket: everything that is *not* tagged with the other languages. If you filter English as `tag:hash-lang-en-us`, old untagged posts disappear from the index. The inverted filter is the whole point.

```yaml
collections:
  /pt-br/articles/:
    permalink: /pt-br/articles/{slug}/
    template: index-pt-br
    filter: tag:hash-lang-pt-br
  /en-us/articles/:
    permalink: /en-us/articles/{slug}/
    template: index-en-us
    filter: tag:-hash-lang-ja-jp+tag:-hash-lang-pt-br+tag:-hash-lang-es-la

```

Same idea for Japanese and Spanish: `tag:hash-lang-ja-jp`, `tag:hash-lang-es-la`. Repeat a collection per locale.

We still attach `#lang-en-us` on every English piece so the theme, hreflang, and the map can see the locale the same way they see Japanese. The inverted filter is only how the **index** stays kind to stragglers.

Public tags such as `Architecture` are topics. They are visible chips. They are not languages. A post can be `#lang-pt-br` \+ `Architecture` \+ `#translation-translation-architecture` at once.

Translate the slug with the article. Do not reuse the English path with `-ja` / `-pt` / `-es` bolted on. You want the URL to reflect the language and content, it's important for SEO:

- English: `i-made-ghost-work-with-multiple-languages`
- Japanese: `ghost-wo-tagengo-ni-shita-hanashi`
- Portuguese: `fiz-o-ghost-funcionar-com-varios-idiomas`
- Spanish: `hice-que-ghost-funcionara-en-varios-idiomas`

The **tags** are still what the software uses to link siblings. The slug is for humans and search.

### Example: one story, four posts

| Locale | Ghost slug                                  | Language tag | Shared group tag                      | Public URL                                                   |
| ------ | ------------------------------------------- | ------------ | ------------------------------------- | ------------------------------------------------------------ |
| en-us  | i-made-ghost-work-with-multiple-languages   | #lang-en-us  | #translation-translation-architecture | /en-us/articles/i-made-ghost-work-with-multiple-languages/   |
| ja-jp  | ghost-wo-tagengo-ni-shita-hanashi           | #lang-ja-jp  | #translation-translation-architecture | /ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/           |
| pt-br  | fiz-o-ghost-funcionar-com-varios-idiomas    | #lang-pt-br  | #translation-translation-architecture | /pt-br/articles/fiz-o-ghost-funcionar-com-varios-idiomas/    |
| es-la  | hice-que-ghost-funcionara-en-varios-idiomas | #lang-es-la  | #translation-translation-architecture | /es-la/articles/hice-que-ghost-funcionara-en-varios-idiomas/ |

Without the language tag, the Japanese body would leak into the English index (because English is “everything that is not ja/pt/es”). Without the group tag, you would have four unrelated posts: the picker would send you to `/ja-jp/` home instead of the Japanese sibling.

Pages can join this map too if they carry both tags. About is often a locale route (`/pt-br/about/`) rather than a collection post, but the map does not care — it scans published posts **and** pages.

### The translation map

Something has to walk published content and keep a post only when **both** tags exist. Drafts stay out of the public map. A published English post with a language tag but no group tag is also invisible to the switcher (it still shows on `/en-us/articles/`).

You serve the result as JSON the theme can `fetch()`:

```
GET /i18n/article-translations.json

```

A trimmed example for this article:

```json
{
  "updated_at": "2026-09-19T00:00:00.000Z",
  "by_url": {
    "https://example.com/en-us/articles/i-made-ghost-work-with-multiple-languages/": "translation-architecture",
    "https://example.com/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/": "translation-architecture"
  },
  "groups": {
    "translation-architecture": {
      "en-us": {
        "url": "/en-us/articles/i-made-ghost-work-with-multiple-languages/",
        "title": "I made Ghost work with multiple languages"
      },
      "ja-jp": {
        "url": "/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/",
        "title": "Ghost を多言語にした話"
      }
    }
  }
}

```

`by_url` is “I am standing on this absolute URL; which group am I in?” `groups` is “in that group, what is the path and title for Japanese?” Paths in `groups` are site-relative; keys in `by_url` are absolute, including scheme and host.

Do not look up `window.location.href`. Query strings, hashes, a missing trailing slash, or `www` vs bare host will miss. Build a key from origin + pathname and force a trailing `/` so it matches what Ghost stored.

Ghost will not build this file for you. I didn't want to keep building it by hand, so I made a small server/app to do it for me.

### Who uses this

Two things, both from the same tags:

1. **People.** The “read in another language” links (and the header picker on a post) fetch the JSON and point at the sibling URLs. No sibling? Drop them on that language’s home or articles list.
2. **Google.** Put `hreflang` in the page `<head>` so search engines know the four URLs are the same story. Ghost can list those posts with `{{#get "posts"}}` filtered by the translation tag. Use `limit="100"`, not `limit="all"` — Ghost 6 ignores “all” anyway.

If the switcher is blank:

- Is the sibling still a draft?
- Did both posts get the **same** group tag, and exactly one `#lang-*` each?
- Did you look up origin + pathname (trailing slash), not the raw `href`?
- Is the sidecar using an API that can actually *see* internal tags? (That is the next section.)

## A tiny Python server for the map

Writing and keeping this map by hand is a pain, so I run a small Python process next to Ghost.

Ghost has two APIs. The public **Content** API hides internal tags (`#lang-*`, `#translation-*`), so your map would come back empty. Use the **Admin** API instead. Make a Custom integration, copy the Admin key, keep it on the server — never in the theme or the browser.

Copy the Admin API key from Ghost Admin → Integrations → your integration. It must be **exactly two parts**: a key id, a colon, then a **hex** secret. The JWT below does `split(":", 1)` and `bytes.fromhex` on the second half. If you build the string from the Admin API JSON, look at `api_keys` where `type` is `admin`. Sometimes `secret` is already `id:hex`. If you then prefix `id` again you get three chunks, `fromhex` dies with `non-hexadecimal number found`, and the map process looks “up” while serving nothing. Count the colons. Two segments. Hex on the right.

The server’s job: ask Admin for published posts and pages, keep rows that have both tags, spit out the JSON above. Standard library only. Docker so it sits on Ghost’s network.

Env vars:

- `GHOST_URL` — how this container reaches Ghost (often `http://ghost:2368`).
- `SITE_URL` — the public origin visitors see, no trailing slash (`https://your-blog.example`). Map keys are built from this, not from `GHOST_URL`.
- `ADMIN_API_KEY` — Ghost Admin API key, `id:secret`. Not the Content API key.

Each request can rebuild the map. That is slow-and-honest on a personal blog. Cache in memory for a minute if you grow and if you still want more performance/faster refreshes, then use Ghost's Webhooks to rebuild and cache.

### `app.py`

Drop this in as `app.py`. Standard library only — short-lived Admin JWT via `hmac`, no `pip install`.

```python
#!/usr/bin/env python3
from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

GHOST_URL = os.environ["GHOST_URL"].rstrip("/")
SITE_URL = os.environ.get("SITE_URL", GHOST_URL).rstrip("/")
ADMIN_API_KEY = os.environ["ADMIN_API_KEY"]
PORT = int(os.environ.get("PORT", "8080"))

LANG_PREFIX = "hash-lang-"
GROUP_PREFIX = "hash-translation-"

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def admin_token() -> str:
    # ADMIN_API_KEY is "id:hexsecret". If `secret` from the Admin API already
    # includes a colon, it is the full key — do not prefix the id again.
    key_id, secret_hex = ADMIN_API_KEY.split(":", 1)
    now = int(time.time())
    header = b64url(
        json.dumps(
            {"alg": "HS256", "typ": "JWT", "kid": key_id},
            separators=(",", ":"),
        ).encode()
    )
    payload = b64url(
        json.dumps(
            {"iat": now, "exp": now + 300, "aud": "/admin/"},
            separators=(",", ":"),
        ).encode()
    )
    signing = f"{header}.{payload}".encode()
    sig = b64url(hmac.new(bytes.fromhex(secret_hex), signing, hashlib.sha256).digest())
    return f"{header}.{payload}.{sig}"

def fetch_all(kind: str) -> list[dict]:
    items: list[dict] = []
    page = 1
    while True:
        query = urllib.parse.urlencode(
            {
                "filter": "status:published",
                "include": "tags",
                "fields": "id,title,slug,url",
                "limit": "50",
                "page": str(page),
            }
        )
        url = f"{GHOST_URL}/ghost/api/admin/{kind}/?{query}"
        req = urllib.request.Request(
            url,
            headers={
                "Accept-Version": "v6.0",
                "Authorization": f"Ghost {admin_token()}",
            },
        )
        with urllib.request.urlopen(req, timeout=30) as response:
            payload = json.loads(response.read().decode())
        items.extend(payload.get(kind, []))
        pages = payload.get("meta", {}).get("pagination", {}).get("pages", 1)
        if page >= pages:
            break
        page += 1
    return items

def locale_and_group(tags: list[dict]) -> tuple[str | None, str | None]:
    locale = group = None
    for tag in tags or []:
        slug = tag.get("slug") or ""
        if slug.startswith(LANG_PREFIX):
            locale = slug.removeprefix(LANG_PREFIX)
        if slug.startswith(GROUP_PREFIX):
            group = slug.removeprefix(GROUP_PREFIX)
    return locale, group

def build_map(entries: list[dict]) -> dict:
    groups: dict = {}
    by_url: dict = {}
    for entry in entries:
        locale, group = locale_and_group(entry.get("tags") or [])
        if not locale or not group:
            continue
        raw = entry.get("url") or f"/{locale}/articles/{entry['slug']}/"
        path = (
            urllib.parse.urlparse(raw).path
            if raw.startswith("http")
            else (raw if raw.startswith("/") else f"/{raw}")
        )
        if not path.endswith("/"):
            path = f"{path}/"
        groups.setdefault(group, {})[locale] = {
            "url": path,
            "title": entry.get("title"),
        }
        by_url[f"{SITE_URL}{path}"] = group
    return {
        "updated_at": datetime.now(timezone.utc).isoformat(),
        "by_url": by_url,
        "groups": groups,
    }

class Handler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        path = self.path.split("?", 1)[0]
        if path == "/health":
            body = b'{"ok":true}'
        elif path == "/article-translations.json":
            body = json.dumps(
                build_map(fetch_all("posts") + fetch_all("pages"))
            ).encode()
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt: str, *args) -> None:
        print(fmt % args)

if __name__ == "__main__":
    print(f"translation map on :{PORT}")
    ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()

```

### Docker

`Dockerfile`:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8080
CMD ["python", "-u", "app.py"]

```

Next to Ghost in Compose:

```yaml
services:
  ghost:
    # your existing Ghost service

  translation-map:
    build: ./translation-map
    environment:
      GHOST_URL: http://ghost:2368
      SITE_URL: https://your-blog.example
      ADMIN_API_KEY: ${GHOST_ADMIN_API_KEY}
    depends_on:
      - ghost

```

PS: This is not a complete production setup, but you can adjust it to your needs. I recommend reading ghost's docs for this: \[[https://docs.ghost.org/install/docker\]](https://docs.ghost.org/install/docker%5D?ref=konogaijin.com)(Docker Ghost Setup) and \[[https://docs.ghost.org/hosting\]](https://docs.ghost.org/hosting%5D?ref=konogaijin.com)(Docker Hosting Guide)

### Hook it to the theme

In the layout:

```html
<script>
  window.__articleTranslationsUrl = '{{@site.url}}/i18n/article-translations.json';
</script>

```

On a post page, `fetch` that URL, then:

```js
const path = location.pathname.endsWith('/') ? location.pathname : `${location.pathname}/`;
const canonical = `${location.origin}${path}`;
const groupId = map.by_url[canonical];
const siblings = groupId ? map.groups[groupId] : null;

```

Draw links for every locale whose `url` is not the current path. The language picker does the same lookup for the locale the reader just chose. No siblings? Fall back to `/{locale}/` or `/{locale}/articles/`.

## Layer 3 — wiring the newsletter form

You already created one Ghost newsletter per language and copied the ids. This layer is only how the **form** picks the right id, and how you attach a list when you publish.

Ghost Members does the signup. You give the form an id:

```html
<form data-members-form="subscribe">
  <label>
    Email
    <input data-members-email type="email" required autocomplete="email" />
  </label>
  <button type="submit">Subscribe</button>
</form>

```

Put your locale → id map where the theme can read it (a static JSON file under the theme, or a tiny inline script — not the Admin API from the browser). Then, from the first path segment, set the attribute Ghost actually cares about:

```js
const locale = location.pathname.split('/').filter(Boolean)[0]; // "pt-br"
form.setAttribute('data-members-newsletter', newsletterIds[locale]);

```

A visitor on `/pt-br/` joins `Newsletter PT-BR`. In the account screen, list every active newsletter as checkboxes so bilinguals can join more than one.

When you **publish**, attach that post to the matching list. The English version → `Newsletter EN-US`. The Portuguese version → `Newsletter PT-BR`. A draft with no newsletter attached will not email anyone. Wrong list, wrong language in the inbox, there are no guardrails here, so don't screw it up.

## A walkthrough: publishing this piece in four languages

Suppose this article is the English source.

**English.** Create a post. Title and body in `en-US`. Slug `i-made-ghost-work-with-multiple-languages`. Tags: `#lang-en-us`, `#translation-translation-architecture`, plus a public topic tag if you want a chip. On publish, assign `Newsletter EN-US`. It appears at `/en-us/articles/i-made-ghost-work-with-multiple-languages/`. The template is English because of the language tag. The switcher stays hidden until a sibling exists.

**Japanese.** A **new** post (not a second body on the English one). Translate title, excerpt, HTML, **and the slug**. Keep in-body code, hrefs, and product names. Slug `ghost-wo-tagengo-ni-shita-hanashi`. Tags: `#lang-ja-jp` and the **same** `#translation-translation-architecture`. Assign `Newsletter JA-JP` when you publish. URL: `/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/`.

Repeat for Portuguese (`fiz-o-ghost-funcionar-com-varios-idiomas` / `#lang-pt-br` / `Newsletter PT-BR`) and Spanish (`hice-que-ghost-funcionara-en-varios-idiomas` / `#lang-es-la` / `Newsletter ES-LA`).

Reload the English page. The widget should offer 日本語 / Português / Español. The header picker on the English URL jumps to the Japanese sibling instead of the Japanese home. Search engines see `hreflang` from Ghost’s tag query. A reader who subscribed on `/es-la/` gets the Spanish send, not four emails of the same story.

If you skip the group tag on Spanish, Spanish still lives at `/es-la/articles/…`, but the other three pages will not link to it. If you skip the language tag on Spanish, it may show up on the English index. If you skip the newsletter, the archive still works; the list does not send.

## Checklist

**For each ghost site**

1. Locale list with three names each, plus a default.
2. Internal `#lang-*` tags in Ghost (and `#translation-*` when you want siblings).
3. One newsletter per locale; copy ids into a map the theme can read.
4. Collections in `routes.yaml` (inverted filter on the default locale) and wrapper templates that stamp `locale` / `bcp47`.
5. JSON dictionaries + compile step for the `ui` helper. Leave Ghost’s own `locales/en.json` for `{{t}}` if you still use it.
6. Sidecar on the Admin API; proxy `/i18n/article-translations.json` onto your public origin. Confirm the Admin key is `id:hex` (two parts), not a doubled `id:id:hex`.
7. If `routes.yaml` points `/` at `locale-detect`, activate the custom theme before you treat `/` as a healthcheck.

**If you want to add more languages**

Locale list → `#lang-*` tag → newsletter + id → locale JSON + compile → collection + wrapper template → teach the map the new `hash-lang-*` prefix.

**Every article**

1. One Ghost post per locale — never four languages in one body.
2. Exactly one `#lang-*` tag per post.
3. The same `#translation-{group-id}` on every sibling you want linked.
4. Public tags for topics only.
5. Translate the slug with the article (not `english-slug-ja`).
6. Attach `Newsletter {LOCALE}` on publish if that version should email.
7. Reload the post after you publish a sibling and check origin + pathname against `by_url`.