[Astro] Multilingual (i18n) Implementation

2026-03-27 hit count image

Sharing how to implement a multilingual system supporting Japanese, Korean, and English in Astro. Covers URL-based routing, translation system, dynamic routing, and more.

astro

Overview

In the previous post Content Collections and Markdown Migration, we covered content management. In this post, I’ll explain in detail the multilingual (i18n) system implemented to support three languages — Japanese, Korean, and English — on this blog.

Astro doesn’t have an official i18n library, but by combining file-based routing with a simple translation system, you can build an effective multilingual site.

Multilingual Routing Strategy

URL Structure

This blog uses URL prefix-based routing.

LanguageURL PatternExample
Japanese (default)/path//astro/installation/
Korean/ko/path//ko/astro/installation/
English/en/path//en/astro/installation/

The default language, Japanese, has no language prefix in the URL, while Korean and English have /ko/ and /en/ prefixes respectively.

Directory-Based Implementation

We implement multilingual support using directory structures, leveraging Astro’s file-based routing.

src/pages/
├── index.astro              # Japanese home
├── [...path].astro          # Japanese posts (catch-all)
├── latest/
│   └── index.astro          # Japanese latest posts
├── search/
│   └── index.astro          # Japanese search
├── ko/
│   ├── index.astro          # Korean home
│   ├── [...path].astro      # Korean posts
│   ├── latest/
│   │   └── index.astro      # Korean latest posts
│   └── search/
│       └── index.astro      # Korean search
└── en/
    ├── index.astro          # English home
    ├── [...path].astro      # English posts
    ├── latest/
    │   └── index.astro      # English latest posts
    └── search/
        └── index.astro      # English search

By placing page files with the same structure in each language directory, we implement independent routing for each language.

Translation System Implementation

UI text translations are centrally managed in src/i18n/translations.ts.

Language Type Definition

// src/i18n/translations.ts
export const defaultLang = 'ja';
export const languages = { ja: '日本語', ko: '한국어', en: 'English' } as const;
export type Lang = keyof typeof languages;
  • defaultLang: Sets the default language to Japanese
  • languages: List of supported languages. Used in language switching UI
  • Lang: TypeScript type as 'ja' | 'ko' | 'en'

Translation Records

export const translations: Record<Lang, Record<string, string>> = {
  ja: {
    'nav.home': 'Home',
    'nav.latest': 'Latest',
    'nav.category': 'Category',
    'latest.title': '最新記事',
    'search.title': '検索',
    'search.placeholder': '検索キーワードを入力...',
    'post.commentEncouragement':
      '私のブログが役に立ちましたか?下にコメントを残してください。',
    'footer.share': 'SHARE',
    'home.title': 'プログラミングでアートを夢見る.',
    // ...
  },
  ko: {
    'nav.home': 'Home',
    'nav.latest': 'Latest',
    'nav.category': 'Category',
    'latest.title': '최신글',
    'search.title': '검색',
    'search.placeholder': '검색어를 입력하세요...',
    'post.commentEncouragement':
      '제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!',
    'footer.share': 'SHARE',
    'home.title': '프로그래밍으로 예술을 꿈꾼다',
    // ...
  },
  en: {
    // ...
  },
};

Key names use dot notation in section.item format for structured management. Using this dot notation, you can organize by area — navigation (nav.), search (search.), posts (post.), etc.

Helper Functions

Here are the helper functions for conveniently using the translation system.

getLangFromUrl — Detect Language from URL

export function getLangFromUrl(url: URL): Lang {
  const path = url.pathname;
  if (path.startsWith('/ko/') || path === '/ko') return 'ko';
  if (path.startsWith('/en/') || path === '/en') return 'en';
  return 'ja';
}

Determines the current language by checking the URL path prefix. Returns the default language (Japanese) if the path doesn’t start with /ko/ or /en/.

useTranslations — Create Translation Function

export function useTranslations(lang: Lang) {
  return function t(key: string): string {
    return translations[lang][key] || translations[defaultLang][key] || key;
  };
}

A closure pattern that takes a language and returns a translation function t(). If a translation doesn’t exist for the given language, it falls back to the default language (Japanese), and if that doesn’t exist either, it returns the key itself.

Here’s how to use it in a component:

---
// Component frontmatter area
import { useTranslations } from '../i18n/translations';
const t = useTranslations('en');
---

<h1>{t('home.title')}</h1>
<!-- Output: Dreaming of art through programming. -->

getLocalizedPath — Generate Language-Specific Paths

export function getLocalizedPath(path: string, lang: Lang): string {
  // Remove existing language prefix
  const cleanPath = path
    .replace(/^\/(ko|en)\//, '/')
    .replace(/^\/(ko|en)$/, '/');
  if (lang === defaultLang) return cleanPath;
  return `/${lang}${cleanPath}`;
}

A function that adds a language prefix to a path. No prefix is added for the default language (Japanese).

getLocalizedPath('/astro/installation/', 'ja'); // → '/astro/installation/'
getLocalizedPath('/astro/installation/', 'ko'); // → '/ko/astro/installation/'
getLocalizedPath('/astro/installation/', 'en'); // → '/en/astro/installation/'

This is useful when creating language switching links. For example, when making language toggle buttons in the Navbar, it’s used to generate URLs for other language versions of the current page.

Dynamic Routing and Language-Specific Pages

Blog posts are handled with catch-all routing using [...path].astro. Each language has its own routing file.

English Post Routing

---
// src/pages/en/[...path].astro
import type { GetStaticPaths } from 'astro';
import PostLayout from '../../layouts/PostLayout.astro';
import { getCollection, render } from 'astro:content';
import { getLocalizedPath } from '../../i18n/translations';

const lang = 'en';

export const getStaticPaths = (async () => {
  const posts = await getCollection('blog', (post) =>
    post.data.lang === 'en' && post.data.published !== false
  );

  return posts.map((post) => {
    const pathStr = post.data.permalink.replace(/^\//, '').replace(/\/$/, '');
    return {
      params: { path: pathStr },
      props: { post },
    };
  });
}) satisfies GetStaticPaths;

const { post } = Astro.props;
const { Content } = await render(post);
---

<PostLayout
  title={post.data.title}
  description={post.data.description}
  image={post.data.image}
  lang={lang}
  permalink={getLocalizedPath(post.data.permalink, lang)}
  category={post.data.category}
  date={post.data.date.toISOString()}
  comments={post.data.comments}
>
  <Content />
</PostLayout>

How It Works

  1. getStaticPaths() retrieves all published posts for the given language (en)
  2. The leading and trailing / are removed from each post’s permalink to use as params.path
  3. During build, a static HTML file is generated for each post
  4. getLocalizedPath() passes the URL with language prefix to PostLayout

For example, an English post with permalink: /astro/installation/:

  • params.path becomes astro/installation
  • The final URL becomes /en/astro/installation/

Japanese (default language) is handled in src/pages/[...path].astro, and Korean in src/pages/ko/[...path].astro. The structure is identical — only the lang value differs.

Language-Specific Google Fonts

To load appropriate fonts for each language, language-specific Google Fonts are configured in Head.astro.

---
// Head.astro excerpt
const fontUrl = lang === 'ja'
  ? 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap'
  : lang === 'ko'
    ? 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap'
    : 'https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap';
---

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href={fontUrl} media="print" onload="this.media='all'" />
  • Japanese: Noto Sans JP
  • Korean: Noto Sans KR
  • English: Noto Sans

The media="print" + onload="this.media='all'" technique loads fonts asynchronously to prevent render blocking.

Sitemap i18n Configuration

The @astrojs/sitemap plugin is configured as follows to generate a multilingual sitemap.

// astro.config.mjs
sitemap({
  serialize(item) {
    item.lastmod = new Date();
    return item;
  },
  i18n: {
    defaultLocale: 'ja',
    locales: {
      ja: 'ja',
      ko: 'ko',
      en: 'en',
    },
  },
}),

The sitemap generated with this configuration includes hreflang tags, allowing search engines to recognize the language-specific versions of each page.

Comparison with Jekyll

In Jekyll, multilingual was implemented using jekyll-multiple-languages-plugin. Here’s a comparison of the two approaches.

ItemJekyllAstro
Multilingual approachPlugin-dependent (jekyll-multiple-languages-plugin)Self-implemented (file-based routing)
Translation filesYAML files (_i18n/en.yml)TypeScript file (translations.ts)
Type safetyNoneTypeScript type support
URL patternDepends on plugin configurationDirectly controlled by directory structure
FallbackPlugin featureHandled in useTranslations function
SitemapSeparate configuration needed@astrojs/sitemap i18n configuration

With Astro, you can implement multilingual support without external plugins — using only file-based routing and simple TypeScript functions — making it more flexible and easier to maintain.

For more details, refer to the previously written Multilingual Plugin post.

Conclusion

In this post, we looked at how to implement a multilingual system in Astro.

  • Support for 3 languages with URL prefix-based routing (/, /ko/, /en/)
  • Centralized UI translation management in translations.ts
  • getLangFromUrl, useTranslations, getLocalizedPath helper functions
  • Language-specific post page generation with [...path].astro catch-all routing
  • Asynchronous loading of language-specific Google Fonts
  • Sitemap i18n configuration

In the next post SEO Implementation, we’ll cover meta tags, structured data, sitemaps, and other implementation methods for search engine optimization.

Series Guide

This post is part of the Jekyll to Astro migration series.

  1. Why I Migrated from Jekyll to Astro
  2. Astro Installation and Project Setup
  3. Content Collections and Markdown Migration
  4. Multilingual (i18n) Implementation
  5. SEO Implementation
  6. Image Optimization — Custom rehype Plugin
  7. Comment System (Utterances)
  8. Ad Integration (Google AdSense)
  9. Search Implementation with Pagefind
  10. Layout and Component Architecture
  11. GitHub Pages Deployment
  12. Social Share Automation Script
  13. Troubleshooting and Tips

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS