Build an Astro Multilingual Blog Without Migrating Existing URLs
Adding a second language to an established Astro blog is not mainly a translation problem. The risky part is changing URLs that search engines, readers, and external links already know. This guide shows how to add an English section under /en/ while every existing default-language URL stays where it is.
The central rule is simple: treat locale as content data and URL policy, not as a string inferred late in rendering.
Start with the URL contract
Suppose an existing Traditional Chinese article is already published at:
/posts/astro-sitemap/Moving it to /zh-tw/posts/astro-sitemap/ would create a migration project: redirects, canonical changes, sitemap churn, and a period in which crawlers reconcile old and new URLs. None of that is necessary just to publish English content.
Astro supports an unprefixed default locale and prefixed secondary locales. The relevant configuration is:
export default defineConfig({ site: "https://example.com/", trailingSlash: "always", i18n: { defaultLocale: "zh-TW", locales: ["zh-TW", "en"], routing: { prefixDefaultLocale: false, redirectToDefaultLocale: false, }, },});With this policy, the observable route contract becomes:
| Content | Repository entry slug | Public URL |
|---|---|---|
| Existing default-language post | astro-sitemap | /posts/astro-sitemap/ |
| New English post | en/pagefind-multilingual-search-astro | /en/posts/pagefind-multilingual-search-astro/ |
The en/ segment in the content collection keeps source files organized. It must not leak into the public slug, or the site will produce /en/posts/en/....
Put locale rules in one module
Route files, post cards, related posts, RSS, and structured data all need the same mapping. A small locale module prevents each caller from inventing its own version.
export const DEFAULT_LOCALE = "zh-TW" as const;export const SUPPORTED_LOCALES = [DEFAULT_LOCALE, "en"] as const;export type Locale = (typeof SUPPORTED_LOCALES)[number];
export function normalizeLocale(value?: string | null): Locale { if (!value?.trim()) return DEFAULT_LOCALE; if (value === "en") return "en"; if (value === "zh-TW" || value === "zh_TW") return "zh-TW"; throw new Error(`Unsupported locale: ${value}`);}
export function getPublicPostSlug(slug: string, locale: Locale): string { if (locale === "en") { if (!slug.startsWith("en/")) { throw new Error(`English post must live below en/: ${slug}`); } return slug.slice(3); }
if (slug.startsWith("en/")) { throw new Error(`Default-language post cannot use en/: ${slug}`); } return slug;}
export function getPostPath(slug: string, locale: Locale): string { const prefix = locale === "en" ? "/en" : ""; return `${prefix}/posts/${getPublicPostSlug(slug, locale)}/`;}Throwing on unknown locales is intentional. A silent fallback can publish a third language into the wrong index or create a canonical URL that does not match the route.
Filter content before sorting it
A multilingual homepage is only one visible part of the system. Previous/next navigation, category counts, tag counts, and related-post scoring can all cross language boundaries if filtering happens after those values are calculated.
Filter the collection first:
async function getSortedPosts(locale: Locale = DEFAULT_LOCALE) { const posts = await getCollection("posts", ({ data }) => !data.draft);
const localizedPosts = posts.filter( (post) => normalizeLocale(post.data.lang) === locale, );
return localizedPosts.sort( (a, b) => b.data.published.valueOf() - a.data.published.valueOf(), );}Only after this step should you assign previous and next posts or calculate related content. Keep the locale argument defaulted to the original language so unchanged callers preserve their behavior.
Generate explicit English routes
Astro file-based routes make the public boundary clear. Keep the original list route at src/pages/[...page].astro, then add an English list route at src/pages/en/[...page].astro.
---import type { GetStaticPaths } from "astro";import { getSortedPosts } from "../../utils/content-utils";
export const getStaticPaths = (async ({ paginate }) => { const posts = await getSortedPosts("en"); return paginate(posts, { pageSize: 10 });}) satisfies GetStaticPaths;---The English article route performs the same locale-specific query and removes the repository prefix when producing route parameters:
return englishPosts.map((entry) => ({ params: { slug: getPublicPostSlug(entry.slug, "en") }, props: { entry },}));Pass lang="en" from this route into the shared layout and UI components. Do not ask components to inspect window.location or browser preferences. Explicit props also work during static rendering and make missing translations easier to detect.
Keep feeds and metadata locale-specific
Create /en/rss.xml from getSortedPosts("en"); keep /rss.xml on the default query. Every feed item link should use the same getPostPath mapping as article cards and Schema.org data.
Each page also needs an absolute self-canonical. An English article should resolve to:
<link rel="canonical" href="https://example.com/en/posts/example-slug/">Do not add hreflang merely because two pages discuss Astro. It describes localized alternatives of the same page. Independent English topics should remain self-canonical without an alternate pair. The decision rules are covered in Hreflang for Independent Multilingual Content.
Verify the generated site, not only TypeScript
Static-site bugs often appear in output paths or HTML rather than in type checking. After pnpm check, run the production build and inspect the artifacts:
pnpm build
test -f dist/index.htmltest -f dist/en/index.htmltest -f dist/rss.xmltest -f dist/en/rss.xml
rg '/en/posts/en/' distrg '<html lang="en"' dist/enrg 'rel="canonical"' dist/en/postsThe first rg command should return no matches. Also inspect the sitemap and confirm that old default-language paths are unchanged.
Pagefind can use the same <html lang> boundary to separate search indexes. Pagefind Multilingual Search in Astro shows how to validate that part of the build.
The practical takeaway is to preserve public URLs first, then make locale explicit at every content and URL boundary. Once routes, queries, metadata, and feeds share that contract, English posts can be independent without destabilizing the existing site.
FAQ
Q: Can I add English pages without moving existing Astro URLs?
A: Yes. Keep the established default-language routes unprefixed and add English routes under /en/. The important part is to make locale explicit in content queries, URL helpers, metadata, feeds, and related-content calculations so the two sections remain separate.
Q: Why does an English Astro post sometimes become /en/posts/en/...?
A: This happens when the content entry slug already contains the repository-only en/ prefix and the route builder adds another English prefix without removing it. Convert the entry slug to a public slug before composing the route, then verify that the production output contains no /en/posts/en/ paths.
Q: Do all English and Traditional Chinese posts need hreflang links?
A: No. Add hreflang only when the URLs are equivalent localized versions of the same article. Independent articles should remain self-canonical even when they share tags or discuss the same framework.
References:
Report a typo or broken link, or suggest a related topic.