Hiroaki Satou profile image Hiroaki Satou

Setting Up Multi Language Blog in Ghost

This guide shows how to create a bilingual Ghost blog with Japanese and English content. It covers routing setup, SEO tags, language detection, meta switching, and font settings. The same approach works for other languages by replacing the language codes.

Setting Up Multi Language Blog in Ghost
Photo by Max Zhdanov / Unsplash

1. First, separate pages by language using the routes file to distinguish between ja and en tags.

Since my blog's default is Japanese, my routes.yaml file looks like this:

routes:

collections:
  /:
    permalink: /{slug}/          # URL for Japanese articles
    filter: "tag:[hash-ja]"      # Only #ja articles

  /en/:
    permalink: /en/{slug}/       # URL for English articles
    filter: "tag:[hash-en]"      # Only #en articles

taxonomies:
  tag: /tag/{slug}/              # Can be divided by language if needed
  author: /author/{slug}/

This feature requires only downloading the routes file from the labs customization section and modifying it.

2. Add #ja and #en tags to each blog post.

sharp-mean inner tag, not show up user view.

<link rel="alternate" hreflang="en" href="https://www.functional-visual.design/en/" />
<link rel="alternate" hreflang="ja" href="https://www.functional-visual.design/" />
<link rel="alternate" hreflang="x-default" href="https://www.functional-visual.design/" />

4. Add JavaScript in Code Injection to dispatch based on language

(I've added userSelectedLanguage to sessionStorage so Japanese users who want to view English pages won't be redirected away)

<script>
  (function() {
    // Check if user has explicitly selected a language
    const userChoseLang = sessionStorage.getItem('userSelectedLanguage');
    
    // Check if current path has a language prefix
    const hasLangPrefix = location.pathname.match(/^\/(en|ja)\//);
    
    // Auto-redirect only if no explicit selection
    if (!userChoseLang && !hasLangPrefix) {
      const lang = navigator.language || navigator.userLanguage;
      // Redirect only English users to /en/
      // Japanese users aren't redirected (Japanese is default)
      if (lang.startsWith('en')) {
        // Make sure not to redirect to the same page
        const newPath = '/en' + (location.pathname === '/' ? '/' : location.pathname);
        if (location.pathname !== newPath) {
          location.replace(newPath);
        }
      }
    }
    
    // Add event handling for language link clicks
    document.addEventListener('DOMContentLoaded', function() {
      const langLinks = document.querySelectorAll('a[hreflang]');
      langLinks.forEach(link => {
        link.addEventListener('click', function() {
          // Record that user has selected a language
          sessionStorage.setItem('userSelectedLanguage', true);
        });
      });
    });
  })();
</script>

5. Add meta tag switching for SEO

<script>
  (function() {
    // ① Define descriptions for each language
    var metaTexts = {
      en: {
        title:       'Functional Visual Design',
        description: 'As a hobbyist designer living with a disability, I pour my passion into design and coding. Here I showcase my projects and share the insights gained along my creative journey.',
        ogLocale:    'en_US'
      },
      ja: {
        title:       'Functional Visual Design',
        description: '障がいを抱える趣味のデザイナーとして、デザインとコーディングに情熱を注いでいます。ここではプロジェクトを公開し、創造の過程で得た洞察を共有します。',
        ogLocale:    'ja_JP'
      }
    };
    
    // ② Determine if the current path starts with /en/
    var lang = location.pathname.startsWith('/en/') ? 'en' : 'ja';
    var cfg  = metaTexts[lang];
    
    // ③ Replace <title>
    document.title = cfg.title;
    
    // ④ Function to update or add meta tags
    function upsertMeta(attr, value, content) {
      var selector = 'meta[' + attr + '="' + value + '"]';
      var el = document.querySelector(selector);
      if (el) {
        el.setAttribute('content', content);
      } else {
        var m = document.createElement('meta');
        m.setAttribute(attr, value);
        m.setAttribute('content', content);
        document.head.appendChild(m);
      }
    }
    
    // Update meta tags
    upsertMeta('name', 'description', cfg.description);
    upsertMeta('property', 'og:description', cfg.description);
    upsertMeta('property', 'og:locale', cfg.ogLocale);
    
    // (Optional: switch canonical links by language)
    // var canonicalUrl = location.origin + location.pathname;
    // var canonicalEl = document.querySelector('link[rel="canonical"]');
    // if (canonicalEl) {
    //   canonicalEl.setAttribute('href', canonicalUrl);
    // } else {
    //   var link = document.createElement('link');
    //   link.setAttribute('rel', 'canonical');
    //   link.setAttribute('href', canonicalUrl);
    //   document.head.appendChild(link);
    // }
  })();
</script>

6. Add Japanese web fonts and custom CSS since the theme doesn't support Japanese

Implement through code injection (this CSS currently doesn't use the theme's variables and will be updated later) to apply to pages that don't contain /en/

<script>
  (function() {
    // Add font script and styles if path doesn't start with /en/
    if (location.pathname.startsWith('/en/')) {
      // do nothing
    } else {
      // ① Dynamically load FontPlus script
      var fp = document.createElement('script');
      fp.src = 'https://webfont.fontplus.jp/accessor/script/fontplus.js?something-inside-the-api-key';
      document.head.appendChild(fp);
      // ② CSS variables & global settings for Japanese fonts
      var style = document.createElement('style');
      style.textContent = `
        html[lang="ja"] {
          --gh-font-heading: TazuganeGothicStdN-Bold;
          --gh-font-body: TazuganeGothicStdN-Regular;
          --logo-author-font-weight: 700;
          --logo-author-letter-spacing: 0.005em;
          --logo-auhtor-line-height: 150%;
          --font-weight-titles: 700;
          --big-author-name-font-size: 16rem;
          --big-author-name-font-weight: 700;
          --big-author-name-line-height: 150%;
          --big-author-name-letter-spacing: 0.005em;
          --full-width-post-font-size: 4.8rem;
          --full-width-post-line-height: 125%;
          --full-width-post-letter-spacing: 0.005em;
          --h1-font-size: 4.8rem;
          --h1-line-height: 150%;
          --h1-letter-spacing: 0.005em;
          --h2-font-size: 3.2rem;
          --h2-line-height: 150%;
          --h2-letter-spacing: 0.005em;
          --h3-font-size: 2.4rem;
          --h3-line-height: 150%;
          --h3-letter-spacing: 0.005em;
          --h4-font-size: 2rem;
          --h4-line-height: 150%;
          --h4-letter-spacing: 0.005em;
          --text-L-semibold-font-size: 1.8rem;
          --text-L-semibold-font-weight: 600;
          --text-L-semibold-line-height: 150%;
          --text-L-semibold-letter-spacing: 0.005em;
          --text-L-medium-font-size: 1.8rem;
          --text-L-medium-font-weight: 500;
          --text-L-medium-line-height: 150%;
          --text-M-semibold-font-size: 1.6rem;
          --text-M-semibold-font-weight: 600;
          --text-M-semibold-line-height: 175%;
          --text-M-semibold-letter-spacing: 0.005em;
          --text-M-medium-font-size: 1.4rem;
          --text-M-medium-font-weight: 500;
          --text-M-medium-line-height: 175%;
          --text-S-bold-font-size: 1.4rem;
          --text-S-bold-font-weight: 700;
          --text-S-bold-line-height: 150%;
          --text-S-bold-letter-spacing: 0.005em;
          --text-S-medium-font-size: 1.4rem;
          --text-S-medium-font-weight: 500;
          --text-S-medium-line-height: 150%;
          --text-XS-bold-font-size: 1.2rem;
          --text-XS-bold-font-weight: 700;
          --text-XS-bold-line-height: 175%;
          --text-XS-bold-letter-spacing: 0.005em;
        }
        /* Global styles along with font variables */
        html[lang="ja"], html[lang="ja"] body {
          font-size: 17px;
          letter-spacing: 0.5%;
        }
      `;
      document.head.appendChild(style);
    }
  })();
</script>

7. Add language options to Navigation

Create a Japanese item with the home URL, and an English item with the home URL + \en\ (since my default is Japanese).

Remaining Issues

My theme includes features like show-favorite-posts and other theme-specific section display options, but these can't be used effectively. To change the display with multiple queries for the theme would require modifying the queries issued by the theme itself. Using show-favorite-posts with the theme's default query would mix Japanese favorite posts into English pages regardless of #ja or #en tags. After much consideration, I decided against customizing the theme internals, as it would require rework with each theme update. The favorite-posts feature wasn't essential for me at this time.

Finally, an introduction to my Ghost hosting service

Being extremely lazy, I didn't want to install Ghost on a Japanese server and write backup scripts myself. Instead of renting a server in Japan, I contracted with a European service that handles Ghost installation, daily backups, and Ghost maintenance. It costs 21,355 yen annually, which isn't cheap, and since I don't use social media, few people find my site through search engines. To make my content accessible to those valuable visitors who do arrive, I've moved my English articles from Medium to Ghost. Like many Ghost hosting services, this host uses CDN, so static page data is stored near Japan and shouldn't load too slowly. The issue is that the backend is dynamically generated, so it's a bit slow from a European server. But the reality is that many easily-contracted Ghost hosting services have servers in Europe or America if convenience is your priority.

Magic Pages
Get your Ghost CMS publication up and running in no time with Magic Pages’ Ghost CMS web hosting – starting at $6/month!