Table of Contents
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.
| Language | URL Pattern | Example |
|---|---|---|
| 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 Japaneselanguages: List of supported languages. Used in language switching UILang: 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
getStaticPaths()retrieves all published posts for the given language (en)- The leading and trailing
/are removed from each post’spermalinkto use asparams.path - During build, a static HTML file is generated for each post
getLocalizedPath()passes the URL with language prefix toPostLayout
For example, an English post with permalink: /astro/installation/:
params.pathbecomesastro/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.
| Item | Jekyll | Astro |
|---|---|---|
| Multilingual approach | Plugin-dependent (jekyll-multiple-languages-plugin) | Self-implemented (file-based routing) |
| Translation files | YAML files (_i18n/en.yml) | TypeScript file (translations.ts) |
| Type safety | None | TypeScript type support |
| URL pattern | Depends on plugin configuration | Directly controlled by directory structure |
| Fallback | Plugin feature | Handled in useTranslations function |
| Sitemap | Separate 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,getLocalizedPathhelper functions- Language-specific post page generation with
[...path].astrocatch-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.
- Why I Migrated from Jekyll to Astro
- Astro Installation and Project Setup
- Content Collections and Markdown Migration
- Multilingual (i18n) Implementation
- SEO Implementation
- Image Optimization — Custom rehype Plugin
- Comment System (Utterances)
- Ad Integration (Google AdSense)
- Search Implementation with Pagefind
- Layout and Component Architecture
- GitHub Pages Deployment
- Social Share Automation Script
- Troubleshooting and Tips
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.