Skip to content
Roland
Go back

Adding bilingual routing to AstroPaper with [...locale]

Edit page

When I rebuilt this blog I picked the AstroPaper theme, whose feature list says i18n ready. Going bilingual revealed what that actually means: the UI strings can be translated, but the site has no locale routing.

The theme does ship useTranslations(), and its components already use getRelativeLocaleUrl(). But src/pages holds a single set of pages, so the build always produces a single-language site. Serving English at / and Chinese at /zh/ means adding the routing layer yourself.

The goal

Step 1: declare the locales

export default defineConfig({
  i18n: {
    locales: ["en", "zh"],
    defaultLocale: "en",
    routing: {
      prefixDefaultLocale: false, // default locale keeps the bare path
    },
  },
});astro.config.ts

prefixDefaultLocale: false is what keeps English at the root. Worth being explicit though: this config generates no pages by itself. It only makes Astro.currentLocale and getRelativeLocaleUrl() behave. The pages are still on you.

Step 2: move pages into [...locale]

This is the heart of the change. From:

src/pages/
├── index.astro
├── about.astro
└── posts/[...page].astro

to:

src/pages/
├── 404.astro                     ← stays out of the locale tree
└── [...locale]/
    ├── index.astro
    ├── about.astro
    └── posts/[...page].astro

Then every page gets a getStaticPaths that emits itself once per locale:

export function getStaticPaths() {
  return LOCALES.map(locale => ({
    params: { locale: localeParam(locale) },
  }));
}

The trick lives in localeParam(): it returns undefined for the default locale. A rest parameter given undefined contributes no segment, so that page lands on the root path.

export const LOCALES = ["en", "zh"] as const;
export const DEFAULT_LOCALE = "en";

export function localeParam(locale: Locale): string | undefined {
  return locale === DEFAULT_LOCALE ? undefined : locale;
}src/utils/locale.ts

Two rest parameters in one route are fine

The paginated route made me pause: [...locale]/posts/[...page].astro puts two rest parameters in a single path. Is that even legal?

It is. In a static build the paths come entirely from getStaticPaths(); rest-parameter matching only matters when the dev server resolves a URL. The paginated route ends up as:

export const getStaticPaths = (async ({ paginate }) => {
  const allPosts = await getCollection("posts", ({ data }) => !data.draft);

  return LOCALES.flatMap(locale =>
    paginate(
      getSortedPosts(allPosts.filter(({ id }) => getEntryLocale(id) === locale)),
      {
        params: { locale: localeParam(locale) },
        pageSize: config.posts.perPage,
      }
    )
  );
}) satisfies GetStaticPaths;

paginate() accepts params, which is what lets the pagination and locale dimensions stack.

Step 3: split content by locale

Posts move into per-locale folders:

src/content/posts/
├── en/rebuild-blog-with-astro.md
└── zh/rebuild-blog-with-astro.md

The locale is then derived from the entry id:

export function getEntryLocale(id: string): Locale {
  const [first] = id.split("/");
  return isLocale(first) ? first : DEFAULT_LOCALE;
}

Every listing filters by the current locale, and tags are collected per locale too — so the Chinese tag cloud never mixes in English-only tags.

One detail to fix: AstroPaper folds content sub-directories into the URL, which would turn Chinese posts into /zh/posts/zh/xxx. Dropping a leading locale segment in the slug helper is enough:

.filter((segment, index) => !(index === 0 && isLocale(segment)))

Step 4: the language switcher

There is a real-world problem here: a post may not exist in the other language. Naively swapping the locale prefix in the current path sends readers from a Chinese post straight into a 404.

My rule: on a post detail page, switching language goes to that locale’s post list; everywhere else it keeps the path.

const switchLocaleUrl = (target: Locale) => {
  const isPostDetail =
    currentPath.startsWith("/posts/") && currentPath !== "/posts";
  const path = isPostDetail ? "posts" : currentPath.replace(/^\//, "");
  return getRelativeLocaleUrl(target, path);
};

Step 5: SEO

Each page emits an hreflang link per locale plus an x-default pointing at the default one:

{
  alternateLinks.map(link => (
    <link rel="alternate" hreflang={link.hreflang} href={link.href} />
  ))
}

The sitemap integration needs to know about the locales as well:

sitemap({
  i18n: {
    defaultLocale: "en",
    locales: { en: "en", zh: "zh" },
  },
}),

Three traps

1. MissingLocaleError

I first set only the theme’s own lang: "zh" and left i18n.locales untouched. The build died while rendering the RSS feed:

MissingLocaleError: The locale/path `zh` does not exist in the configured `i18n.locales`.

getRelativeLocaleUrl() only accepts locales declared in i18n.locales. The theme-level lang field is a different thing entirely.

2. Demo posts that refuse to die

The content collection globs **/[^_]*.{md,mdx}. Underscore-prefixed files are excluded — but ordinary files inside an underscore-prefixed directory are not. The theme’s _releases/ notes happily rendered as blog posts until I deleted the folder.

3. Dates ignore the locale

The date component hard-codes dayjs.format("D MMM, YYYY"), so Chinese pages still printed 17 Sep, 2026. Swapping in Intl fixes it:

const date = new Intl.DateTimeFormat(LOCALE_TAGS[locale], {
  year: "numeric",
  month: "short",
  day: "numeric",
}).format(datetime.toDate());

Chinese pages now read 2026年9月17日 while English keeps Sep 17, 2026. Same treatment for month names on the archive page.

Wrapping up

The work concentrates in the routing layer and content organisation; the components barely need touching — provided the theme already calls getRelativeLocaleUrl() and useTranslations().

The final build emits 19 pages: two complete sites, each with its own feed and OG images. If you run AstroPaper, or any Astro theme with a similar shape, [...locale] plus a getStaticPaths that returns undefined for the default locale is the smallest change that gets you there.


Edit page
Share this post:

Previous Post
Rebuilding the blog: from Gridsome to Astro