Internationalising a service business site used to mean either picking a translation SaaS (Lokalise, Crowdin, CMS i18n plugins — all subscription gates), or hand-rolling locale routing and frontmatter duplication across 50 posts in 4 languages. Both paths are painful. Astro 5's native i18n routing flips the script: define locales once, route /en/, /es/, /fr/ automatically, override brand.json per locale, and ship global without a single translation vendor.
This post walks Velocity X's production i18n setup: why you internationalise later not earlier, how Astro's i18nDomainMapping and localeRedirect work, swapping brand strings per locale without multiplying files, and the 6-question pattern to decide whether i18n is worth the upfront cost.
The Case for Internationalising Later, Not Day 1
Most founders internationalise too early. They scaffold a 3-language site before their primary market is profitable, waste engineering time on translation coordination, and complicate every feature deployment ("did we update all 3 versions?"). The better path: nail one market, ship confidence with users in that language, then expand.
Astro's i18n makes that expansion simple. You don't need to architect for i18n from day one. You build English first. When Spanish revenue starts flowing, you switch on /es/ routing, drop in locale overrides, and deploy. The JavaScript bundle doesn't bloat. The build doesn't slow. Your existing English site stays exactly the same.
This is the opposite of traditional SaaS i18n (gettext, react-intl, vue-i18n), where you add translation markers to every component and manage locale state runtime-wide. Astro's approach is static: routes are i18n. Everything else is unchanged.
How Astro i18n Routes Work
Astro 5 added i18n.routing config. You define your primary locale (let's say English), secondary locales (Spanish, French, German), and a routing strategy (prefix routing, domain routing, or hybrid).
The Config
{`// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr', 'de'],
routing: {
prefixDefaultLocale: false, // /en stays /en, not /
},
},
});`}
That's the core. /en/services gets its own route, /es/services gets its own route, and Astro handles the 404 + redirect logic automatically. No middleware, no manual routing, no locale detection library.
Organising Locale-Specific Routes
For category and item pages driven by brand.json overrides, use dynamic routing with locale awareness:
{`// src/pages/[locale]/[category].astro
import { getStaticPaths } from 'astro';
import { getLocales } from 'astro:i18n';
export async function getStaticPaths() {
const locales = getLocales();
const categories = ['web-design', 'branding', 'strategy'];
return locales.flatMap(locale =>
categories.map(cat => ({
params: { locale, category: cat },
props: { locale },
}))
);
}
const { locale } = Astro.props;
const brand = await import(\`../../data/brands/\${locale}/brand.json\`);
---`}
Astro pre-renders every locale × category combination at build time. No runtime locale detection, no state management, no middleware. Static HTML for every route.
Locale-Specific Brand JSON Without File Duplication
The real magic is locale-specific content. Each language doesn't need its own copy of brand.json — only the strings that differ.
Create a locale-aware loader:
{`// src/lib/locale-brand.ts
import defaultBrand from '../data/brand.json';
export async function getBrand(locale: string) {
try {
const localeOverride = await import(
\`../data/brands/\${locale}/brand.json\`
);
return { ...defaultBrand, ...localeOverride.default };
} catch (e) {
// Fallback to default if no override exists
return defaultBrand;
}
}
// Usage:
const brand = await getBrand('es'); // Merges es/brand.json into default`}
Now your Spanish site reuses hero layout, component structure, and links from the default brand — only category titles, CTAs, and hero copy override. One source of truth, N language variants. A feature added to the default brand automatically appears in all locales.
Redirecting Users to Their Preferred Locale
Landing on / should route users smartly: if they accept Spanish headers and you have /es/, go there. Otherwise, default to /en/.
Astro's i18n.routing.localeRedirect handles this at the middleware level:
{`// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
routing: {
prefixDefaultLocale: false,
localeRedirect: 'chosen', // Redirect to user's Accept-Language or default
},
},
});`}
If a user's browser sends Accept-Language: es-MX, Astro redirects them to /es/. If they send Accept-Language: it-IT (Italian, not supported), they fall back to your defaultLocale (English). No JavaScript required — it's a 302 redirect at the edge.
6 Questions Before You Internationalise
1. Is your primary market + language fully profitable? If you're still figuring out GTM in English, internationalising adds complexity you don't need. Ship dominance in one language first.
2. Do you have native speakers or a translator on payroll? Machine translation is fast but risky for B2B copy. If you're outsourcing to a freelancer, factor in review cycles and revision overhead.
3. Is your secondary market revenue-ready? Translating for countries where you can't yet deliver (payment methods, legal, support) is premature. Internationalise when you're operationally ready to serve that market.
4. Can you maintain N versions of every page? If you add a blog post, new pricing tier, or service update, you need to update all locales. Plan for that burden upfront.
5. Does your site structure support locale isolation? If every page is interconnected (sitemap, related links, nav), multiplying by N locales multiplies complexity. Astro handles routing — you handle content consistency.
6. Is SEO per-locale important? If you're targeting Spain + Argentina separately (different keywords, different search volume), you need separate locale structures. If you're just offering a Spanish translation of English content, one locale is fine.
If you answer yes to 4+ of these, internationalise. Otherwise, wait.
Scaling to 10 Locales Without Exploding Complexity
Velocity X supports this pattern for global service agencies: one brand.json, N locale overrides. Because routes are static and content is pre-rendered, adding a 10th locale doesn't slow your build. You're adding N new static files, not new runtime complexity.
The scaling question is maintenance, not engineering. Each new locale means your team is responsible for translating every update. That's a content problem, not a code problem. Astro lets you solve it cleanly — all locale logic lives in src/lib/locale-brand.ts and locale-specific JSON. Your components don't know they're multi-locale; they just render the brand they're handed.
See our brand.json schema reference for the exact structure to override per locale.
The Catch: Translation Tooling
Astro's i18n routing is native, but Astro doesn't provide a translation dashboard. You're managing .json files by hand or writing custom tooling. For teams under 10 people, that's fine — version control is your tool. For teams with non-technical editors, you'll eventually want a CMS with translation workflow built in.
That's the trade-off: Astro gives you free routing + static rendering, but you're responsible for the translation pipeline. Lokalise or Crowdin would handle that pipeline for $100–500/month. Whether it's worth it depends on your team size and translation frequency.
The Verdict
If you're a service business expanding to 2–4 markets and your team can handle translation + review: Astro's native i18n is unbeatable. Routes are automatic, brand overrides are clean, and your build stays fast. No subscription SaaS, no runtime locale state, no bundle bloat. See how we build for global service agencies — this is the architecture.
Internationalise when revenue justifies it, keep it simple, ship once.