Translate a Phoenix LiveView Site: Multi-Language SEO in Mishka CMS

A hands-on guide to a multi-language Mishka CMS site: set the languages and address style, translate a page, a post and the interface words, and check hreflang and right to left with curl.

Translate a Phoenix LiveView Site: Multi-Language SEO in Mishka CMS

A Mishka CMS site can publish its pages and blog posts in more than one language, and it writes the right address, text direction and hreflang tags for each one, all set from the admin with no deploy. This guide adds Persian to an English site: it sets the languages, picks the address style, translates a page, a post and the words in your templates, then checks the head with curl. The code ran against mishka_cms_core 0.0.5-alpha.9 and mishka_blog 0.0.3-alpha.5.

Time left until the offer ends
114
D
23
H
56
M
05
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

Mishka CMS is free and open source. A founding seat pays for the time we spend on it and on the extensions around it, and it gives you lifetime access, priority support and every paid extension we release.

You need a Mishka CMS site, an admin account that holds admin:dashboard:setting, and for the blog steps the Blog extension. Persian titles can also keep their own script in the address, which Unicode Slugs in Elixir: Persian, Hindi and Japanese URLs shows.

Newsletter

Mishka releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam
1list of languages: site.languages
2address styles to choose from
11right to left language codes known
0restarts after a change

The six steps at a glance

1 · LanguagesSet the languagesOne list, first code leads.
2 · AddressesChoose the address stylePrefix the first language or not.
3 · PageTranslate a pageA copy at its own address.
4 · PostTranslate a postAn edition in a language group.
5 · WordsTranslate interface wordst() words, no deploy.
6 · CheckCheck hreflang and RTLRead the head with curl.

How translation fits together

In Mishka CMS a translation is a page of its own, at its own address, grouped with the original. The site keeps one ordered list of languages, and a page answers a language only when the list names it. Every address, switcher link, hreflang tag and sitemap entry is built from the same editions, so none of them can disagree.

Diagram of a Mishka CMS site in two languages: the site's language list and address style, the editions of one page in a language group, and what the site writes from them: hreflang tags, a language switcher, html lang and dir, and one sitemap URL per edition

The site list decides which editions answer, and the head, the switcher and the sitemap all follow the editions.

A page has a language and a language_group_id. The original is its own group, and a translation carries the original's id. A group holds one page per language, and the CMS refuses a second one. Blog posts, collections and tags use the same two fields.

Step 1: Set the languages

The languages are one setting on the Settings screen. Open Settings, then the General tab, type en, fa in Languages and click Save Changes.

The General tab of Settings with Languages set to en, fa and the Language in addresses select below it

Languages and Language in addresses on the General tab.

The CMS trims each code, lowercases it and drops repeats. A list that is empty, or holds text that is not a code, such as Persian, is not saved. The first code is the site's first language, and there is no separate default setting: to change which language leads, move its code to the front.

Step 2: Choose the address style

Language in addresses has two styles, and both keep every stored path as it is. The default leaves the first language without a prefix. The other puts a prefix on every language. Pick one on a new site, because it decides which address search engines see for each page.

Chart of what a visitor gets for /blog, /en/blog and /fa/blog when the first language is unprefixed and when every language is prefixed

What /blog, /en/blog and /fa/blog answer in each style, for a site with en first and fa second.

With the default style, /en/blog is sent with a 301 to /blog, so each page has one address. In a template, build links with site_path/2 so they follow the style you chose:

heex
<.link navigate={site_path("/:lang/about", @page.language)}>About</.link>

The address rules are plain functions, so you can run them without a site. In a scratch project with mishka_cms_core 0.0.5-alpha.9 in mix.exs, this script fills the settings cache by hand and asks for the pricing page in both styles:

elixir
Application.ensure_all_started(:localize)
:ets.new(:mishka_cms_settings, [:named_table, :public])
:ets.insert(:mishka_cms_settings, {{nil, "site.languages"}, %{"value" => ["en", "fa"]}})

alias MishkaCmsCore.Runtime.Languages

for mode <- Languages.url_modes(), lang <- ["en", "fa", "de"] do
  IO.inspect(Languages.url("/:lang/pricing", %{"lang" => lang}, nil, mode), label: "#{mode} #{lang}")
end

Run it with mix run --no-start script.exs. A language outside the list gives no address at all:

text
prefix_others en: "/pricing"
prefix_others fa: "/fa/pricing"
prefix_others de: nil
prefix_all en: "/en/pricing"
prefix_all fa: "/fa/pricing"
prefix_all de: nil

Step 3: Translate a page

A translation starts as an inactive copy of the page, so its address stays closed until you translate and publish it. Under Runtime, click Pages, find the page and click Add translation on its row.

  1. In Language, type the code, such as fa. The box suggests the site's languages that the page has no edition in yet.
  2. Check Path. It follows the language until you type your own: /pricing becomes /fa/pricing.
  3. Click Create translation.
The Add translation dialog over the Pages list, with Language fa, Path /fa/pricing and the Create translation button

The Add translation dialog suggests the path from the language.

The copy keeps the template, layout, permissions, SEO tags and code. Only the name, path, language and Active differ, and its name is the original's followed by the language, such as pricing-fa. Open it in the Visual Editor, replace the text, then change Title and the SEO Description on the Configuration tab. Click Draft, choose Published and click Publish.

Animation in five steps: add fa to the site's languages, add a translation of a page, translate and publish it, the head lists en, fa and x-default, and the reader switches language without a reload

From the language list to a live Persian page, in five steps.

Step 4: Translate a blog post

A translated post is a post of its own with its own slug and content, joined to the original by a language group. Open Blog Posts Management and click Add Post.

  1. In Language, enter the code, such as fa.
  2. In Language Group, search for the original and choose it. The default option starts a separate group instead.
  3. Write the translated Title and Content, and give it a slug no other post uses. A Persian slug can stay in Persian script.
  4. Click Create.

The Posts page shows one row for each original, with a chip such as EN and FA for each edition. Readers pick a language in the header's dropdown, which offers each published edition at its own slug, and the post's head lists them as alternates. A post page answers only for a post written in the page's language: /fa/blog/<english-slug> finds nothing. Publish a post in more than one language has the full list of rules.

Step 5: Translate the interface words

The words inside a template are not part of a translated page, so you mark them with t() and translate them per language on the Translations screen. A page is a database row, which is why translation files cannot reach it.

heex
<h2>{t("Categories")}</h2>
<p>{t("Read %{count} posts", count: @count)}</p>

Save the template and the CMS adds a row for each word in every language the site publishes in. Under Runtime, click Translations, click Translate on the row and type the text. For a word, the CMS looks first at the site's own row, then the global row, then the word itself, so an untranslated word still shows in English.

Step 6: Check hreflang and right to left

Ask for the head of both editions and read it. This ran against a Mishka CMS dev site with en first and fa second, at localhost:4030:

bash
SITE=http://localhost:4030
for path in / /fa; do
  echo "== $path"
  curl -s $SITE$path | grep -oE '<html[^>]*>|<(link|meta)[^>]*(hreflang|og:locale)[^>]*>' \
    | sed -E 's/ data-phx-loc="[0-9]+"//; s/ data-mishka-seo//'
done
html
== /
<html lang="en" dir="ltr">
<meta content="en_US" property="og:locale">
<meta content="fa_IR" property="og:locale:alternate">
<link href="http://localhost:4030" hreflang="en" rel="alternate">
<link href="http://localhost:4030/fa" hreflang="fa" rel="alternate">
<link href="http://localhost:4030" hreflang="x-default" rel="alternate">
== /fa
<html lang="fa" dir="rtl">
<meta content="fa_IR" property="og:locale">
<meta content="en_US" property="og:locale:alternate">
<link href="http://localhost:4030" hreflang="en" rel="alternate">
<link href="http://localhost:4030/fa" hreflang="fa" rel="alternate">
<link href="http://localhost:4030" hreflang="x-default" rel="alternate">

Three things in this output are what hreflang needs. Each page lists itself and the other edition, both pages list the same set, so every link has a link back, and one x-default points at the first language. The Persian page also gets dir="rtl".

The same tags are written on a post that has two or more published editions, by Mishka Blog, and a post in one language gets none.

Try it

Start with the Mishka CMS page and the Mishka CMS documentation, or read the source and the Hex package.

Concepts are in Languages and language editions, and the words are in Translate interface words with t(). A small team builds this. If you want to help us work on it full time, a founding seat gives you lifetime access, priority support and every paid extension we release.

Time left until the offer ends
114
D
23
H
56
M
05
S
Claim your seat · $250One payment, no subscription. Questions first? Contact us.

You can also sponsor our open source work, or hire our team for an Elixir or Phoenix LiveView project. Found a bug? Please open an issue on GitHub: Mishka CMS is young and we read every one.

Newsletter

Mishka releases. Once a month.

New Mishka CMS releases, Elixir and Phoenix patterns, and case studies from the team. No spam, unsubscribe anytime.

✓ once a month✓ no spam

Questions people ask about translating a site

Open Settings, then the General tab, type the language codes in Languages, such as en, fa, and click Save Changes. The first code is the site's first language. The change applies on the next request with no restart, and it creates no pages: you add a translation for each page or post.

Yes. A page or blog post that exists in two or more languages gets a hreflang link for each language, an x-default link at the first language, an og:locale tag for its own language and og:locale:alternate tags for the others. A page in one language gets none.

Yes. The page's html tag gets lang and dir from its language: Arabic, Persian, Hebrew, Urdu and a few others read right to left, and the rest left to right. A live language change keeps both right.

Either works, and you choose it in Language in addresses. The default leaves the first language unprefixed, so /blog is English and /fa/blog is Persian, and /en/blog is sent with a 301 to /blog. The other style puts a prefix on every language. Switching rewrites no page.

Click Add Post, enter the new language code, choose the original post in Language Group, write the translated title and content, give it a slug no other post uses and click Create. Each translation is a post of its own, and readers switch between editions in the header's language dropdown.

Yes, with their own screen. Wrap a word in t() in a template, then translate it per language on the Translations screen under Runtime, with no deploy. Until a language has a translation, the page shows the word itself.

Thank you

Mishka CMS stands on Elixir, Phoenix LiveView and Ash Framework, and on the libraries in its dependency list. Thank you to everyone who builds them, and to you for reading this far.

Contributors Mishka Group Core
Shahryar Tavakkoli
LEAD
Shahryar Tavakkoli @shahryar-tavakkoli
Elixir · Phoenix · LiveView

Shahryar Tavakkoli writes about Elixir and Phoenix LiveView: Mishka Chelekom releases, GuardedStruct validation, static sites, service workers and SEO.

Comments

Sign in to comment

No comments yet.