이 페이지와 원하는 AI 어시스턴트를 사용하여 문서를 요약합니다
이 페이지의 콘텐츠는 AI를 사용하여 번역되었습니다.
영어 원본 내용의 최신 버전을 보기이 문서를 개선할 아이디어가 있으시면 GitHub에 풀 리퀘스트를 제출하여 자유롭게 기여해 주세요.
문서에 대한 GitHub 링크문서의 Markdown을 클립보드에 복사
TanStack Start i18n: locale routing, SSR and the type tax
TanStack Start ships no i18n layer, and unlike Next.js or Nuxt there is no first-party module to fill the gap. So the interesting question is not "which library", it is how a locale fits into a generated, type-safe route tree without breaking every Link in the app. This post covers the wiring: the route segment, the negotiation, SSR and prerender, and the honest state of the options.
Table of Contents
Put the locale in the URL, not in a cookie
The tempting shortcut is a cookie: read it on the server, set the locale, done. It works in development and fails everywhere else. One URL then serves several languages, so you cannot emit hreflang, you cannot prerender a page per locale, and a crawler sees whichever language your negotiation happens to pick.
A route segment fixes all three at once. In TanStack Router's file-based routing that means a directory:
코드를 클립보드에 복사
The braces make the segment optional. {-$locale} matches /about and /fr/about, which is what you want when the default locale has no prefix. If every locale is prefixed, use plain $locale instead. Be careful stacking it with other dynamic segments in the same route, {-$locale}/$slug is ambiguous by construction.
Negotiate in beforeLoad, not in a component
beforeLoad runs on the server and on the client, before the route renders, and it can throw a redirect. That is the right hook for locale work: an unknown prefix must 404 rather than silently resolve.
코드를 클립보드에 복사
Without that check, /xx/about matches the segment, renders in your default language, and gets indexed as a duplicate of /about. This is the single most common i18n bug I see on file-based routers.
Accept-Language negotiation for a first visit belongs one layer lower, in server middleware, so the redirect happens before the router boots. Intlayer's Vite plugin registers a proxy that does this and writes the locale cookie; if you wire it yourself, remember to exclude /api and asset paths.
The type tax nobody mentions
This is the part that surprises people. TanStack Router generates routeTree.gen.ts and types every to prop against it. Add a locale directory and every path in your app changes shape:
코드를 클립보드에 복사
You do not want that literal in three hundred call sites. The usual fix is one wrapper component that strips the prefix from the public type and re-adds it at runtime:
코드를 클립보드에 복사
Do the same for useNavigate. Budget an afternoon for this and a codemod pass over existing links: it is real work, and it is independent of which translation library you pick.
SSR, hydration and prerender
Resolve the locale from route params in the root shell, and server and client cannot disagree, because both read the same URL:
코드를 클립보드에 복사
Read the locale from navigator.language or localStorage during render instead, and you get a hydration mismatch on every visitor whose browser disagrees with the server. The symptom is React error #418 in production and text that flickers from one language to another.
Prerendering is where the URL choice pays off. You expand your path list across locales and hand TanStack Start a page per localized route:
코드를 클립보드에 복사
One gotcha specific to this stack: if you colocate content files inside src/routes, the router will try to turn them into routes. Exclude them with routeFileIgnorePattern in the tanstackStart() plugin options.
Loaders vs static strings
Two different things get conflated. UI strings ("Add to cart", "Loading") are static, known at build time, and belong in the component, not in a loader. Localized data (a product description from your CMS) is a fetch, and belongs in loader so it streams with the route.
Route metadata sits between the two, because head runs outside the React tree. You have three options and they trade off differently:
테이블을 모달로 열어 모든 데이터를 명확하게 확인
Sync read in head | Async read in head | Read in loader, use in head | |
|---|---|---|---|
head signature | synchronous | async | synchronous, reads loaderData |
| Locales shipped | all declared locales | requested locale only | requested locale only |
| Cost | bigger route chunk | dictionary import on the head critical path, so slightly worse LCP | content threaded through loaderData |
The loader variant with staleTime: Infinity is the one I would default to on a real site: loaders of matched routes run in parallel, and the result is cached per locale.
The options today
The ecosystem here is thin. Most TanStack Start apps wire react-i18next by hand, because that is what the React answers on the internet describe, and it does work.
테이블을 모달로 열어 모든 데이터를 명확하게 확인
| Option | How you wire it | Note |
|---|---|---|
react-i18next | Manual: provider in the root shell, catalogs per namespace, your own loader for SSR | Largest ecosystem. Around 17.3 kB in the TanStack benchmark, and namespace discipline is on you |
use-intl | Manual, same shape, ICU messages | Avoids the Next.js specific traps of next-intl; leakage problem is identical |
| Lingui | lingui extract / lingui compile in the build | Compile step, ICU. I found the workflow heavier than it needed to be with no clear win here |
| Paraglide | Codegen of message functions | The advertised tree-shaking did not hold up in the benchmark, and it re-reads the locale from storage per node instead of a store |
| Intlayer | Vite plugin, build-time compilation | Covers routing, prerender and sitemap; smaller ecosystem |
None of them solve the route-tree typing problem for you. That wrapper is yours to write either way.
Intlayer on TanStack Start
Intlayer's model is one content file per component, compiled at build time into per-component dictionaries. For this stack the practical difference is that locale routing, the prerender pass and the sitemap are covered by the same package rather than assembled from three.
코드를 클립보드에 복사
코드를 클립보드에 복사
useIntlayer inside components, getIntlayer or getIntlayerAsync outside the React tree (route head, loaders, server functions). In a server function, getLocale reads the cookie or header for you. Setup is npx intlayer init, plus the intlayer() plugin in vite.config.ts. As a data point, intlayer.org itself runs on TanStack Start.
What it costs you. A build plugin is mandatory: no plugin, no dictionaries, and that includes your test runner config. The project is younger and much smaller than i18next, so when something breaks on a TanStack Start canary you are reading source, not Stack Overflow. ICU message format is not fully supported, which matters if a translation vendor delivers ICU strings today. And the per-component model means you cannot hand a translator one big JSON file without going through the CLI or the CMS.
Coming from an existing codebase, the @intlayer/react-i18next compat adapter keeps useTranslation() and t() working while Intlayer serves the content, and the migration guide covers moving off it afterwards.
Common mistakes
- Locale in a cookie only. No
hreflang, no prerender, one URL per page for every language. Fix the URL scheme first, pick the library second. - No prefix validation.
/xx/aboutrendering your default language is a duplicate-content bug that only shows up in Search Console, months later. - Reading the locale on the client during render. Route params are the only source both sides agree on.
- A
<button>locale switcher. Crawlers do not click. Render locale links as anchors pointing at the localized URL. - Content files inside
src/routes. They become routes unless you setrouteFileIgnorePattern. - Awaiting dictionaries serially in
head. UsePromise.allif a head reads more than one.
Going further
- Full TanStack Start setup guide, step by step
- TanStack Start i18n benchmark: bundle size, leakage, locale-switch timings
- Drop-in
react-i18nextcompat adapter and the react-i18next migration guide - The wider React i18n landscape
- Per-component vs centralized i18n
- Getting hreflang right on a multilingual site
- Routing modes, domains and URL schemes in the configuration reference
댓글
아직 댓글이 없습니다. 첫 번째로 의견을 나눠보세요.
