Development Blog Jekyll [Jekyll] Implementing Multilingual Support (KO/EN/JA) Without Plugins

Overview

Sharing the experience of implementing Korean, English, and Japanese support in a Jekyll blog with the Minimal Mistakes theme — without any plugins.

Background

Running the blog in Korean only felt limiting. Since it’s a tech blog, having it accessible to international readers seemed worthwhile. With AI translation quality being good enough these days, I decided to go with three languages.

There are Jekyll multilingual plugins like jekyll-polyglot and jekyll-multiple-languages-plugin, but most aren’t supported on GitHub Pages and theme compatibility was a concern. I ended up implementing everything with just Liquid templates and _config.yml settings.

Overall Architecture

1. Directory Layout

The core idea is separating posts and pages into language-specific directories.

_posts/
├── ko/          # Korean (default language)
│   ├── development/
│   ├── pc/
│   └── ...
├── en/          # English
│   ├── development/
│   ├── pc/
│   └── ...
└── ja/          # Japanese
    ├── development/
    ├── pc/
    └── ...

_pages/
├── categories/        # Korean category pages
├── en/categories/     # English category pages
└── ja/categories/     # Japanese category pages

2. URL Structure

Every URL is prefixed with a language code.

Language URL Pattern Example
Korean /ko/categories/title/ /ko/development/blog/jekyll/post-title/
English /en/categories/title/ /en/development/blog/jekyll/post-title/
Japanese /ja/categories/title/ /ja/development/blog/jekyll/post-title/

Key Implementation Details

1. _config.yml Scope Settings

The defaults section in _config.yml sets language-specific defaults based on directory paths. This is the foundation of the entire multilingual system.

defaults:
  # Korean posts
  - scope:
      path: "_posts/ko"
      type: posts
    values:
      permalink: /ko/:categories/:title/
      sidebar:
        nav: "menu"

  # English posts
  - scope:
      path: "_posts/en"
      type: posts
    values:
      lang: en
      permalink: /en/:categories/:title/
      sidebar:
        nav: "menu-en"

  # Japanese posts
  - scope:
      path: "_posts/ja"
      type: posts
    values:
      lang: ja
      permalink: /ja/:categories/:title/
      sidebar:
        nav: "menu-ja"

There’s an important design decision here. Korean posts don’t get a lang field. Since Korean is the default language, posts without lang are treated as Korean. Only English and Japanese posts explicitly have lang: en or lang: ja. This asymmetric structure affects Liquid filtering throughout the site.

2. Translation Linking via ref Field

The ref field connects Korean, English, and Japanese versions of the same post. Posts with the same ref value are treated as translations of each other.

# Korean post
---
title: "SSH  생성하기"
ref: generating-ssh-keys
---

# English post
---
title: "Generating SSH Keys"
ref: generating-ssh-keys
lang: en
---

# Japanese post
---
title: "SSH鍵の生成"
ref: generating-ssh-keys
lang: ja
---

The language dropdown in the masthead uses this ref to find translated URLs.

{% assign ref_posts = site.posts | where: "ref", page.ref %}
{% for p in ref_posts %}
  {% if p.lang == target_lang %}
    <a href="{{ p.url }}">{{ lang_name }}</a>
  {% endif %}
{% endfor %}

When a translation doesn’t exist, it falls back to the target language’s home page.

3. Language Detection and Redirect

When a user visits the root URL (/), the site detects the browser language and redirects to the appropriate language page.

function detectLang() {
  var supported = ['ko', 'en', 'ja'];
  // 1. Check localStorage for saved preference
  var stored = localStorage.getItem('preferred_lang');
  if (stored && supported.indexOf(stored) !== -1) return stored;

  // 2. Detect browser language
  var bl = (navigator.language || navigator.userLanguage || '').toLowerCase();
  if (bl.indexOf('ko') === 0) return 'ko';
  if (bl.indexOf('ja') === 0) return 'ja';
  if (bl.indexOf('en') === 0) return 'en';

  // 3. Default
  return 'en';
}
window.location.replace('/' + detectLang() + '/');

When a user manually selects a language, the choice is saved to localStorage and takes priority on subsequent visits.

4. Maintaining Language on Back Navigation

There was an issue where pressing the browser’s back button would break the language context. For example, if you were reading in English, switched to Korean, then pressed back — you’d see the English page again. This was solved by detecting back/forward navigation via performance.getEntriesByType('navigation') and redirecting to the preferred language version.

var nav = performance.getEntriesByType('navigation')[0];
if (nav && nav.type === 'back_forward') {
  var pref = localStorage.getItem('preferred_lang');
  if (pref && pref !== currentLang) {
    location.replace(translatedUrl);
  }
}

The pageshow event’s persisted property is also checked to handle pages restored from the bfcache.

5. Separate Navigation Menus

Three separate menu trees are defined in _data/navigation.yml.

menu:      # Korean sidebar
  - title: "Development"
    url: /ko/development/
    children:
      - title: "Apple"
        url: /ko/development/apple/

menu-en:   # English sidebar
  - title: "Development"
    url: /en/development/
    children:
      - title: "Apple"
        url: /en/development/apple/

menu-ja:   # Japanese sidebar
  - title: "Development"
    url: /ja/development/
    children:
      - title: "Apple"
        url: /ja/development/apple/

Repeating the same structure three times may seem inefficient, but it provides the flexibility to customize category names and URLs per language. Since _config.yml scopes already specify which menu each language uses, individual posts and pages don’t need to worry about it.

6. Liquid Filtering Pattern

A consistent pattern for filtering posts by language is used across all layouts. The asymmetric lang field handling requires two-branch conditions.

{% assign current_lang = page.lang | default: "ko" %}

{% for post in site.categories[taxonomy] %}
  {% if current_lang == "ko" %}
    {% unless post.lang %}
      <!-- Korean: show only posts WITHOUT lang field -->
      {% include archive-single.html %}
    {% endunless %}
  {% else %}
    {% if post.lang == current_lang %}
      <!-- EN/JA: show only posts with matching lang -->
      {% include archive-single.html %}
    {% endif %}
  {% endif %}
{% endfor %}

This pattern appears in category pages, tag pages, the home layout, and post pagination.

7. i18n UI Text

UI strings used in layouts (previous post, next post, etc.) are defined in _data/i18n.yml.

ko:
  prev_post: "이전 글"
  next_post: "다음 글"

en:
  prev_post: "Previous"
  next_post: "Next"

ja:
  prev_post: "前の記事"
  next_post: "次の記事"

Used in layouts like this:

{% assign current_lang = page.lang | default: "ko" %}
{% assign i18n = site.data.i18n[current_lang] %}
{{ i18n.prev_post }}

8. SEO hreflang Tags

To inform search engines about translated versions, hreflang tags are automatically generated in the <head>.

{% if page.ref %}
  {% assign ref_posts = site.posts | where: "ref", page.ref %}
  {% assign ref_pages = site.pages | where: "ref", page.ref %}
  {% assign ref_all = ref_posts | concat: ref_pages %}
  {% for item in ref_all %}
    <link rel="alternate" hreflang="{{ item.lang | default: 'ko' }}"
          href="{{ item.url | absolute_url }}">
  {% endfor %}
{% endif %}

This tells Google about alternate language versions so it can serve the right one in search results.

Lessons Learned

1. No lang Field for Korean

I initially wanted to set lang: ko on Korean posts, but this conflicted with the Minimal Mistakes theme’s default behavior. The theme treats posts without a lang field as the default language in many places. I adopted an asymmetric structure where only non-default languages get explicit lang fields.

This decision means Liquid filtering always splits into two branches: unless post.lang for Korean, if post.lang == current_lang for others. The code is slightly messier but maintains theme compatibility.

2. Triple Category Page Maintenance

Every category page must exist in all three languages. Each new category means creating three page files and adding entries to three menus in navigation.yml. Automation would be nice, but custom plugins aren’t available on GitHub Pages, so manual work is unavoidable.

3. Back Navigation Language Confusion

When switching from English to Korean, the browser history retains the English URL. Pressing back shows the English page while the user assumes they’re in Korean mode. The back_forward detection + localStorage combination solved this, but the implementation required handling bfcache edge cases, making it more involved than expected.

Summary

Implementing multilingual support in Jekyll without plugins involves significant manual work and repetition. The trade-off is guaranteed compatibility with GitHub Pages and flexibility when updating themes.

Here’s a summary of the key components:

Component Implementation
URL routing _config.yml defaults scope
Translation linking ref field matching
Language detection JavaScript navigator.language + localStorage
Menu separation Language-specific menu trees in navigation.yml
Post filtering Liquid lang field conditional branching
UI text _data/i18n.yml
SEO hreflang alternate tags

References

Leave a comment