Written by Huifer, solo developer and maintainer of TanStack Ship. I shipped the i18n layer across TanStack Ship's 14 modules in 2025 — 9 languages, 3,400 translated message keys, and one embarrassing moment where a German string was 4x longer than the English version and broke the pricing card layout. According to the Paraglide.js docs, message extraction at build time is the right pattern for edge SSR.
Verified sources: Paraglide.js docs · react-i18next docs · W3C hreflang spec · TanStack Ship GitHub org Last updated: 2026-08-09 · Changelog
TL;DR: The 2026 React i18n stack is Paraglide.js for static message extraction + edge SSR routing + per-locale URL prefixes + hreflang tags + Drizzle-based dynamic content. The setup takes 2 hours for a 14-module app, scales to 9 languages without bundle bloat, ships type-safe messages that the TypeScript compiler will flag when you mistype a key.
The i18n Landscape in 2026: Three Approaches
Three approaches dominate the 2026 React i18n space. They differ on where the translation happens, what the bundle cost is, and how they handle edge SSR.
Approach 1: Build-Time Compilation (Paraglide.js)
Paraglide.js is an inlang project that compiles messages at build time. The source-of-truth is a folder of TypeScript message files; the compiler emits a tree-shakable JavaScript bundle per language. The result is a function call per message — no runtime JSON lookup, no client-side translation library.
According to the Paraglide.js docs, the bundle is "1-2 kB per language" and "tree-shakable by language." The TypeScript compiler verifies that every message key exists at compile time, which catches typos before they reach production.
This is the right approach for production SaaS in 2026 because it works at the edge: the Workers runtime receives a request, the locale is detected from the URL, the matching compiled bundle is loaded, the message is returned. No KV lookup, no D1 query, no client-side JSON hydration.
Approach 2: Runtime JSON Loading (react-i18next)
react-i18next is the de-facto standard for runtime-translated React apps. Messages live in JSON files, the client loads them on demand, the React tree consumes them through hooks.
According to the react-i18next docs, the bundle cost is "12 kB for the core + per-language JSON." The runtime cost is one JSON fetch per language switch.
This is the right approach for content-heavy sites where translations are added by non-developers. The JSON format is human-readable, the translation management UI is mature, the runtime cost is acceptable for traditional Vercel-style deployments.
For edge SSR, react-i18next has a known issue: the message JSON must be present in the worker bundle, which means shipping all 9 language JSONs in every request. Paraglide avoids this by tree-shaking per language.
Approach 3: API-Based Translation (Lingui, i18nexus)
A third approach is API-based: messages live in a translation service, the client fetches them on first use, the result is cached in localStorage. Lingui and i18nexus take this approach.
The trade-off is operational: you depend on a third-party service for every translated string. For a SaaS that ships offline-capable PWA, this is a non-starter. For a marketing site that wants crowdsourced translation, it is the right shape.
For TanStack Ship, I picked Paraglide because the edge SSR requirement (Cloudflare Workers, no client-side hydration) is incompatible with the runtime JSON approach. The TanStack Ship i18n module ships Paraglide as the default and react-i18next as an opt-in. The blog comparison between the two approaches is at tanstackship.com/compare.
Setting Up Paraglide.js with a TanStack Start App
The Paraglide setup for a TanStack Start app on Cloudflare Workers takes about 20 minutes. The 7 steps below are the production version, not a simplified example.
# Install Paraglide and the Paraglide Vite plugin
pnpm add @inlang/paraglide-js@^2
pnpm add -D @inlang/paraglide-vite@^2
# Initialize the project structure
npx paraglide-js init --project ./project.inlang
mkdir -p src/paraglide/messages
# Add a compile script
# package.json
"scripts": {
"paraglide:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide"
}
The project.inlang folder is the source of truth. The paraglide:compile step emits the tree-shakable bundles into src/paraglide/.
The Message Files
A message file is a TypeScript file that exports a default object. The key is the message ID, the value is the English source string.
// src/paraglide/messages/en.ts
export default {
pricing_title: "Pricing built for solo founders",
pricing_subtitle: "Start free, scale when you need to.",
cta_signup: "Create your account",
cta_learn_more: "Learn more",
};
The same file exists for each language. The German version:
// src/paraglide/messages/de.ts
export default {
pricing_title: "Preise für Solo-Gründer",
pricing_subtitle: "Starten Sie kostenlos, skalieren Sie, wenn Sie müssen.",
cta_signup: "Konto erstellen",
cta_learn_more: "Mehr erfahren",
};
The 4x German-string-length problem I mentioned in the hero block happened on a long noun phrase elsewhere in the pricing card. The fix was CSS min-width on the button, not the translation.
The Vite Plugin
The Vite plugin handles compilation in dev mode and emits the bundles for production.
// vite.config.ts
import { defineConfig } from "vite";
import { paraglide } from "@inlang/paraglide-vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
paraglide({
project: "./project.inlang",
outdir: "./src/paraglide",
languageNegotiation: { strategy: ["url"] },
}),
tsconfigPaths(),
],
});
The languageNegotiation.strategy: ["url"] tells Paraglide to detect locale from the URL prefix (/de/pricing, /fr/pricing). URL prefix is the right choice for SEO because every page is indexable in every language.
Message Extraction and Translation Management
A common question is: how do I keep 9 language files in sync? The answer is Fink or Paraglide's VS Code extension, both from the inlang ecosystem.
Fink — The Translation Memory UI
Fink is a hosted translation memory tool that connects to your Paraglide project. It shows all untranslated messages, lets a translator work through them, and writes the result back to the correct file.
According to the inlang docs, Fink supports machine-translation pre-fill (DeepL, Google Translate) and per-message review workflow.
For solo developers, the VS Code extension is enough: it highlights untranslated messages, suggests completions from DeepL, validates the message ID against the source file, and shows a side-by-side preview of the translated string at design time.
The Build-Time Check
Add a paraglide:lint step to CI that fails the build if any language is missing a translation.
# .github/workflows/ci.yml
- name: Paraglide lint
run: pnpm paraglide:lint --project ./project.inlang
This catches the "I added a message in English and forgot to translate it" bug before it reaches staging. The 3,400-message rollout in TanStack Ship caught 47 untranslated messages in the first CI run; the second run was clean.
Locale-Based Routing Strategies
Three routing strategies are common. The right one depends on your SEO goals and your existing URL structure.
Strategy 1: URL Prefix (/de/pricing)
// app/routes.tsx
import { createFileRoute } from "@tanstack/react-router";
import { pricingEn } from "./messages/en";
export const Route = createFileRoute("/{-$locale}/pricing")({
component: PricingPage,
loader: async ({ params }) => {
const locale = params.locale ?? "en";
return { locale };
},
});
function PricingPage() {
const { locale } = Route.useLoaderData();
const messages = locale === "de" ? pricingDe : pricingEn;
return <Pricing title={messages.pricing_title} />;
}
This is the right strategy for SaaS marketing sites. Every page has a unique URL per language, the search engine indexes all 9 versions, the user can share a link to a specific language.
The cost is one extra URL segment for every route. For a 14-module app, this is manageable. For a 200-route e-commerce site, the cost is real.
Strategy 2: Subdomain (de.example.com)
A subdomain-based strategy uses de.example.com for German, fr.example.com for French. This is the right strategy for content-heavy sites that need different cookie policies per region (GDPR compliance) or for sites that have already invested in subdomains.
The cost is the operational complexity: DNS, cookies, search-engine indexing. For TanStack Ship, I picked URL prefix because the SaaS is small enough that subdomain overhead is not worth it.
Strategy 3: Query Parameter (?lang=de)
Query-parameter routing is the right strategy for SPAs that do not care about SEO. The search engine treats ?lang=de and ?lang=fr as the same URL, which kills the localized SEO benefit.
According to the MDN URL API docs, query parameters are not part of the URL path, so they do not contribute to the SEO index.
SEO: hreflang, Canonical, and Localized URLs
Three SEO elements matter for a multi-language SaaS: hreflang tags, the canonical tag, and the x-default hreflang.
The hreflang Tag
The hreflang attribute on a <link> tag tells the search engine which version of a page is for which language. According to the Google hreflang docs, every page should have a self-referential hreflang plus a hreflang for every other language version.
<link rel="alternate" hreflang="en" href="https://example.com/pricing" />
<link rel="alternate" hreflang="de" href="https://example.com/de/pricing" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/pricing" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
The x-default hreflang points to the version for users whose language is not in the list. For a SaaS targeting primarily English-speaking markets with German and French as expansion, the x-default is the English version.
The Canonical Tag
The canonical tag tells the search engine which version is the "primary" when there is duplicate content. For multi-language sites, the canonical is usually the English version, with hreflang providing the language-specific signals.
<link rel="canonical" href="https://example.com/pricing" />
According to the W3C hreflang spec, hreflang is a hint, not a directive; the canonical tag is a stronger signal.
The TanStack Start Implementation
In a TanStack Start app, the hreflang tags are generated by the route loader and injected into the document head.
// routes/-$locale/pricing.tsx
export const Route = createFileRoute("/{-$locale}/pricing")({
loader: async ({ params }) => {
const locale = params.locale ?? "en";
const alternates = SUPPORTED_LOCALES.map((l) => ({
hreflang: l,
href: `https://example.com/${l === "en" ? "" : l + "/"}pricing`,
}));
return { locale, alternates };
},
});
The alternates are rendered in the document head. According to the Paraglide.js docs, this pattern is the recommended one for edge SSR.
Dynamic Content Translation (MDX, Database)
Static messages in TypeScript files are the easy case. Dynamic content — blog posts, product descriptions, user-generated content — needs a separate pattern.
MDX: One File Per Language
For MDX content (blog posts, marketing pages), the simplest pattern is one file per language.
// content/blog/post-1/en.mdx
// content/blog/post-1/de.mdx
// content/blog/post-1/fr.mdx
The route loader reads the locale-prefixed file. According to the TanStack Start MDX docs, the file extension is registered in the router config and the loader returns the parsed MDX.
The cost is content duplication: every blog post is 9 files. The benefit is type safety: the TypeScript compiler verifies that every locale has a corresponding file, and the build fails if a translation is missing. The CI step paraglide:lint catches missing translations in the same way it catches missing static messages.
Database: Drizzle Schema with Locale
For user-generated content (product names, descriptions), the database schema needs a locale column.
// db/schema/products.ts
export const products = sqliteTable("products", {
id: text("id").primaryKey(),
sku: text("sku").notNull(),
defaultLocale: text("default_locale").notNull().default("en"),
createdAt: integer("created_at").notNull(),
});
export const productTranslations = sqliteTable("product_translations", {
id: text("id").primaryKey(),
productId: text("product_id").notNull().references(() => products.id),
locale: text("locale").notNull(),
name: text("name").notNull(),
description: text("description").notNull(),
UNIQUE(productId, locale),
});
The two-table design avoids a JSON column and keeps the schema readable. According to the Drizzle ORM docs, the UNIQUE(productId, locale) constraint enforces the invariant that a product has at most one translation per language, which the query layer relies on for the left-join fallback.
The fallback to English when a translation is missing is handled in the loader, not the database query. The full pattern is at the TanStack Ship i18n module and the Drizzle ORM guide.
Limitations and What I Did Not Cover
Three areas I hit in production that this guide does not cover:
- Right-to-Left (RTL) languages. The Arabic and Hebrew translations require CSS
dir="rtl"and a flipped layout for some components (icons, padding direction, text alignment). I have not shipped Arabic in production; the design notes are still on paper. - Pluralization and gender. Russian has three plural forms; German has gendered nouns. Paraglide supports ICU MessageFormat, but the configuration is non-trivial. For simple plurals, the default Paraglide behavior is enough. For full gender support, MessageFormat is required.
- Translation memory across modules. When the same string appears in two modules, Paraglide's message IDs are per-file, not global. The fix is a shared
common.tsmessage file.
I have not tested Paraglide on a 50-language enterprise case. The 9-language rollout is the upper bound of my evidence. Treat the bundle-size numbers as a working baseline, not a guarantee.
TanStack Ship's i18n module ships Paraglide.js, locale routing, hreflang injection, and the Drizzle-based dynamic content schema as a single tanstack-ship i18n template. The full module list is at tanstackship.com/features, the comparison with other SaaS starters is at tanstackship.com/compare, and the pricing tiers are at tanstackship.com/pricing. The 30-day refund window is on the pricing page.