Serving multiple languages well is mostly about three things: correct URLs, correct
hreflang, and a build pipeline that never silently ships English to a Japanese user. This post is the architecture I run for a 7-language SaaS on Vercel — including the mistakes that taught me each rule.
The seven URL patterns I considered (and the one I picked)
| Pattern | Example | Pro | Con |
|---|---|---|---|
| Subdomain | ja.korlens.app/about |
Clean separation | DNS, SSL per language |
| Subdirectory | korlens.app/ja/about |
One project, easy crawl | Routing logic in middleware |
| Query string | korlens.app/about?lang=ja |
Trivial | Bad for SEO, Google ignores |
| Accept-Language only | korlens.app/about |
One URL | Breaks share links, bad for SEO |
Subdirectory wins for indie scale. One Vercel project, one analytics view, one sitemap. Routes look like:
/ → English (default, no prefix)
/ko/... → Korean
/ja/... → Japanese
/zh/... → Simplified Chinese
/es/... → Spanish
/fr/... → French
/de/... → German
The decision that took me two rewrites: the default language has no prefix. /about is English; /ja/about is Japanese. Search engines treat / as the canonical English page; /ja/about declares its alternates via hreflang. If you prefix every language equally (/en/about), you have to deal with two canonical English URLs and a / that 308s to /en/. It works but it's noisy.
Edge middleware: 30 lines, runs on every request
// middleware.ts (Next.js, runs on Vercel Edge)
import { NextResponse, type NextRequest } from "next/server";
const LOCALES = ["ko", "ja", "zh", "es", "fr", "de"]; // 'en' is default, no prefix
const PUBLIC_FILE = /\.(.*)$/;
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Skip static files and API
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
// First-visit redirect from Accept-Language (only on bare /)
if (pathname === "/") {
const cookie = req.cookies.get("NEXT_LOCALE")?.value;
const accept = req.headers.get("accept-language") || "";
const detected = cookie || accept.split(",")[0]?.slice(0, 2);
if (detected && LOCALES.includes(detected)) {
return NextResponse.redirect(new URL(`/${detected}`, req.url));
}
}
return NextResponse.next();
}
export const config = { matcher: "/((?!_next|api).*)" };
Two rules I learned the hard way:
-
Never auto-redirect on pages other than
/. If a user shares/ja/pricing, they want Japanese — don't sniff their browser and bounce them to/de/pricing. -
Persist the choice in a cookie the first time a user explicitly picks a language. Otherwise Accept-Language flip-flopping (Chrome on iOS sometimes reports
en-US,ko;q=0.9) creates loops.
hreflang at build time, not runtime
The single biggest SEO win was generating <link rel="alternate" hreflang="..."> tags at build time, not in middleware. Google needs them in the HTML on first response. Pattern:
// app/[locale]/about/page.tsx
export function generateMetadata({ params }: { params: { locale: string } }) {
const slug = "/about";
const alternates: Record<string, string> = { "x-default": `https://korlens.app${slug}` };
for (const l of ["ko","ja","zh","es","fr","de"]) {
alternates[l] = `https://korlens.app/${l}${slug}`;
}
alternates["en"] = `https://korlens.app${slug}`;
return {
title: t(slug, params.locale, "title"),
alternates: { canonical: `https://korlens.app/${params.locale === "en" ? "" : params.locale + slug}`.replace(/\/$/, slug), languages: alternates },
};
}
Two gotchas:
-
x-defaultmatters. Without it Google guesses which language to show users in countries you don't target. -
Self-referential
hreflangis required. The Japanese page must list itself ashreflang="ja". Skip it and Search Console will flag "no return tag".
Sitemap: one file per language
I split the sitemap by language because it makes broken-link triage 7× faster:
/sitemap.xml ← index
/sitemap-en.xml
/sitemap-ko.xml
/sitemap-ja.xml
...
Vercel's edge function generates each on demand from a single pages.json. Total cold build is under 600ms even with 4,000 URLs because the file is read once and reused per locale.
Translation pipeline: source of truth is en.json
The mistake I made in v1 was letting translators edit per-language JSON files directly. Drift was instant — Korean had 412 keys, German had 287, Spanish had 401. Fix:
/messages
en.json ← canonical
ko.json
ja.json
...
/scripts
check_keys.ts ← CI: diffs every locale against en.json, exits 1 on mismatch
CI fails any PR where a locale has a missing key OR an extra key. Translators submit changes via a small Sheet → JSON script; English is the only language a developer edits.
For machine pre-translation I run a nightly LLM pass that translates new English keys into the six target languages with this prompt shape:
You are a professional translator for a SaaS UI. Source: en.json key "{key}" = "{value}".
Translate to {target_lang}.
Rules:
- Keep placeholders like {name}, {count} unchanged.
- Prefer noun-form labels for buttons (e.g. "Save" → "保存" not "保存する").
- Output JSON: {"value": "...", "confidence": 0..1}
Anything below confidence 0.8 is queued for human review and falls back to English at runtime. Anything above ships.
Performance: same numbers, seven languages
- First Contentful Paint p75 across all locales: 0.8 - 1.1s (Vercel Edge, Singapore + Frankfurt regions).
- TTFB p75: 120 - 180ms regardless of locale (middleware adds < 5ms).
-
Build time: 38s for 7 locales × ~50 routes = 350 generated pages. The trick is
generateStaticParamsreturning all locale × slug pairs so the build parallelizes across CPU cores.
What I'd do differently if starting again
-
Use
next-intlfrom day one instead of rolling my own message loader. The migration was three days of unnecessary work. - Pick exactly the locales you'll maintain, not "all the ones I might want someday." Each locale is a CI test, a translator review, and a debug surface forever.
-
Treat Korean and Japanese as separate brand voices, not translations of English. The pages that converted best in
/ko/were rewritten from scratch, not translated.
If you're shipping anything beyond 3 locales, the architecture above scales to 10+ without changes — same middleware, same pages.json, just more JSON files. The hard part is never the routing. It's keeping en.json honest and the hreflang self-referential.
Top comments (0)