Language Routing & i18n
How the bilingual (JA/EN) routing, locale detection, and content translation system works.
Language Routing & i18n
This document explains how the bilingual (Japanese / English) routing system works across the site. Japanese is the default locale served at the root, and English pages are served under the /en/ prefix.
Astro i18n Configuration
Astro’s built-in i18n routing handles the two locales. Configuration is in astro.config.ts:
// astro.config.ts
i18n: {
defaultLocale: 'ja',
locales: ['ja', 'en'],
routing: {
prefixDefaultLocale: false,
},
},
This means:
- Japanese pages are served at the root (
/notes/,/products/, etc.) - English pages are served under
/en/(/en/notes/,/en/products/, etc.) - No
/ja/prefix for the default locale
Page Structure
src/astro/pages/
├── notes/[slug].astro # → /notes/[slug]/
├── guides/[slug].astro # → /guides/[slug]/
├── products/[slug].astro # → /products/[slug]/
├── brands/[slug].astro # → /brands/[slug]/
├── categories/[slug].astro # → /categories/[slug]/
├── tags/[slug].astro # → /tags/[slug]/
├── s/[slug].astro # → /s/[slug]/
├── en/
│ ├── notes/[slug].astro # → /en/notes/[slug]/
│ ├── guides/[slug].astro # → /en/guides/[slug]/
│ ├── products/[slug].astro # → /en/products/[slug]/
│ ├── brands/[slug].astro # → /en/brands/[slug]/
│ ├── categories/[slug].astro # → /en/categories/[slug]/
│ ├── tags/[slug].astro # → /en/tags/[slug]/
│ └── s/[slug].astro # → /en/s/[slug]/
└── ...
Locale Detection
Locale Type
export const LOCALES = ['ja', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'ja';
Layout-Level Context
Astro layouts detect the locale from the URL and pass it to components:
---
const locale = Astro.currentLocale ?? 'ja';
const dictionary = await getDictionary(locale);
---
<html lang={locale}>
<!-- layout content -->
</html>
Accessing Locale in Components
Astro components use Astro.currentLocale:
---
const locale = Astro.currentLocale ?? 'ja';
---
React components (client islands) receive locale as a prop:
function MyComponent({ locale = 'ja' }: { locale?: Locale }) {
const name = getProductName(product, locale);
}
MDX File Convention
File Naming
Content directories contain paired MDX files:
src/mdx/notes/
├── col003-mixers.mdx # Japanese article
├── col003-mixers.en.mdx # English article
├── first-post.mdx
└── first-post.en.mdx
| File | Locale | Slug | URL |
|---|---|---|---|
col003-mixers.mdx | JA | col003-mixers | /notes/col003-mixers/ |
col003-mixers.en.mdx | EN | col003-mixers | /en/notes/col003-mixers/ |
Both files share the same slug. The .en.mdx suffix is stripped to derive the slug.
Content Directories
| Directory | JA URL | EN URL |
|---|---|---|
src/mdx/notes/ | /notes/[slug]/ | /en/notes/[slug]/ |
src/mdx/highlights/ | /notes/[slug]/ | /en/notes/[slug]/ |
src/mdx/guides/ | /guides/[slug]/ | /en/guides/[slug]/ |
src/mdx/products/ | /products/[slug]/ | /en/products/[slug]/ |
src/mdx/s/ | /s/[slug]/ | /en/s/[slug]/ |
Article Loading via Content Collections
Astro content collections handle article loading. Collections are defined in src/astro/content.config.ts:
Collection Structure
// JA collections — load from src/mdx/
const notes = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/mdx/notes' }),
});
// EN collections — load .en.mdx files with slug stripping
const notesEn = defineCollection({
loader: glob({
pattern: '**/*.en.mdx',
base: './src/mdx/notes',
generateId: ({ entry }) => entry.replace(/\.en\.mdx$/, ''),
}),
});
There are 5 JA collections (products, guides, notes, highlights, standalone) and 5 corresponding EN collections (products-en, guides-en, notes-en, highlights-en, standalone-en).
Querying Content
import { getCollection, getEntry } from 'astro:content';
// Get all JA notes
const jaArticles = await getCollection('notes');
// Get all EN notes
const enArticles = await getCollection('notes-en');
// Get a specific article
const article = await getEntry('notes', 'col003-mixers');
Static Page Generation
Astro pages use getStaticPaths() to generate pages from collections:
---
export async function getStaticPaths() {
const articles = await getCollection('notes');
return articles.map((article) => ({
params: { slug: article.id },
}));
}
---
---
export async function getStaticPaths() {
const articles = await getCollection('notes-en');
return articles.map((article) => ({
params: { slug: article.id },
}));
}
---
Product & Brand i18n
Master Data Fields
Product and brand data store both JA and EN fields side by side:
{
slug: 'iromihon-acr-glasscyan-s',
name: 'ブランクパネル: Iromihon-ACR GlassCyan-S', // JA
nameEn: 'Blank Panel: Iromihon-ACR GlassCyan-S', // EN
description: 'アクリル製カラーブランクパネル...', // JA
descriptionEn: 'Acrylic color blank panel...', // EN
}
{
slug: 'addac',
description: '音による表現のための楽器...', // JA
descriptionEn: 'Instruments for sonic expression...', // EN
descriptionMore: 'ポルトガル・リスボン発の...', // JA
descriptionMoreEn: 'ADDAC System is a modular synth...', // EN
}
Helper Functions
// Returns nameEn for 'en' locale, falls back to name
getProductName(product, locale)
// Returns descriptionEn for 'en' locale, falls back to description
getProductDescription(product, locale)
// Same pattern for brands
getBrandDescription(brand, locale)
getBrandDescriptionMore(brand, locale)
All exported from lib/i18n/index.ts.
Component Usage
Components receive locale and use the helpers:
function ProductNavItem({ product, locale = 'ja' }) {
const pathPrefix = locale === 'en' ? '/en' : '';
const name = getProductName(product, locale);
const description = getProductDescription(product, locale);
const href = `${pathPrefix}${product.detailHref}`;
// ...
}
Tag & Category System
Dual Labels
Tags and categories have ASCII keys with localized display labels:
const CATEGORY_MAPPINGS = [
{ key: 'news', label: 'お知らせ', labelEn: 'News' },
{ key: 'products-intro', label: '商品紹介', labelEn: 'Product Introduction' },
{ key: 'guide', label: 'ガイド', labelEn: 'Guides' },
{ key: 'column', label: 'コラム', labelEn: 'Columns' },
];
MDX frontmatter always uses the ASCII key:
categories:
- products-intro
tags:
- how-to-build
Labels are resolved at render time via getTaxonomyLabel(key, locale).
URL Path Helpers
// Build locale-aware paths
localePath('/products/', 'ja') // → '/products/'
localePath('/products/', 'en') // → '/en/products/'
// Language switcher (alternate locale path)
getAlternatePath('/products/', 'ja') // → '/en/products/'
getAlternatePath('/en/products/', 'en') // → '/products/'
getAlternatePath() includes a whitelist of known EN top-level pages to avoid linking to 404s for pages without English translations.
SEO: hreflang Alternates
Pages include <link rel="alternate" hreflang="..."> tags in the HTML head for cross-locale linking:
<link rel="alternate" hreflang="ja" href="/notes/" />
<link rel="alternate" hreflang="en" href="/en/notes/" />
These are generated from layout or head components based on the current locale and the existence of a corresponding translation.
Translation Workflow
To translate JA content into EN, the project provides Claude Code automation:
en-translator agent
A custom Claude Code agent (.claude/agents/en-translator.md) that translates a JA .mdx file into an .en.mdx file. It has built-in synth terminology awareness and preserves all MDX components, links, and frontmatter structure. Uses the opus model for translation quality.
/l-create-en-implementation skill
A skill (.claude/skills/l-create-en-implementation/SKILL.md) that automates the full EN implementation workflow. Invoke it while checked out on the JA feature branch — it auto-detects the current branch’s PR:
- Auto-detect the current branch’s PR and identify changed files
- Categorize changes (MDX content, routes, components, data)
- Create an EN branch from the JA PR’s head
- Translate MDX files using the
en-translatoragent - Scaffold EN routes if new JA routes were added
- Update
enComponentsand data fields as needed - Validate with
pnpm check - Create a PR targeting the JA PR’s head branch
This ensures JA and EN changes merge together when the JA PR lands on main.
Summary of Key Principles
- Japanese is default — no URL prefix, served at root
- English is prefixed — explicit
/en/in URLs - Dual file naming —
article.mdx(JA) +article.en.mdx(EN) share the same slug - Fallback to JA — English helpers fall back to Japanese content when no EN field exists
- Astro =
Astro.currentLocale, React = props — locale via Astro API for.astrofiles, props for React islands - All i18n in
lib/i18n/— centralized types, paths, helpers - Separate content collections — JA and EN articles use parallel collections (
notes/notes-en)