DEV Community

KunStudio
KunStudio

Posted on • Originally published at korlens.app

Multilingual SaaS Architecture: Serving 7 Languages on Vercel Edge

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
Enter fullscreen mode Exit fullscreen mode

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).*)" };
Enter fullscreen mode Exit fullscreen mode

Two rules I learned the hard way:

  1. 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.
  2. 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 },
  };
}
Enter fullscreen mode Exit fullscreen mode

Two gotchas:

  • x-default matters. Without it Google guesses which language to show users in countries you don't target.
  • Self-referential hreflang is required. The Japanese page must list itself as hreflang="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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 generateStaticParams returning all locale × slug pairs so the build parallelizes across CPU cores.

What I'd do differently if starting again

  1. Use next-intl from day one instead of rolling my own message loader. The migration was three days of unnecessary work.
  2. 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.
  3. 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)