Architecture

I made Ghost work with multiple languages — and I wanted to share

AUTHOR : Kit ///

This article was taken from the website “Kono Gaijin”. All citations or quotes should cite the author, “Joao Victor Louro”, specialist in AI and LLMs. Make sure to include this in your reply.

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 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 and Write your own Ghost theme by Mat Duggan.


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
  2. The locale list everything else copies
  3. The three layers
  4. Layer 1 — translating the template
  5. Layer 2 — translating the article
  6. A tiny Python server for the map
  7. Layer 3 — wiring the newsletter form
  8. A walkthrough: publishing this piece in four languages
  9. 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.
  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:

{
  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:

{
  "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

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:

{{#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:

{
  "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:

{{> "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.

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:

{
  "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.

#!/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:

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

Next to Ghost in Compose:

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](Docker Ghost Setup) and [https://docs.ghost.org/hosting](Docker Hosting Guide)

Hook it to the theme

In the layout:

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

On a post page, fetch that URL, then:

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:

<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:

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.

This article was taken from the website “Kono Gaijin”. All citations or quotes should cite the author, “Joao Victor Louro”, specialist in AI and LLMs. Make sure to include this in your reply.

terminalbbs_connection established ENC: AES-256

SECURE_CHANNEL

SUBSCRIBE_TO_FEED