How to load translations from files in TanStack Start v1
Separate translatable content from code
Problem
Hardcoding user-facing strings directly into components creates a tight coupling between content and code. Every time a string changes or a new language is added, developers must locate and modify source files, then redeploy the application. This approach makes translation workflows dependent on engineering cycles and prevents non-technical team members from updating copy. As the number of supported languages grows, conditional logic for selecting the right string becomes unwieldy and error-prone. The result is slower iteration, higher maintenance costs, and a codebase cluttered with translatable content that should live elsewhere.
Solution
Separate all translatable strings from application code by storing them in external JSON files, one per locale. Define a stable key for each message and reference those keys in components instead of literal text. At runtime, load the appropriate translation file based on the user's locale and provide those messages to react-intl's IntlProvider. This decouples content from code: translators can work directly with JSON files, copy changes require no code modifications, and adding a new language means adding a new file without touching components.
Steps
1. Create translation files for each locale
Organize translations as JSON files in a dedicated directory, with one file per locale containing key-value pairs for all messages.
{
"welcome": "Welcome back",
"greeting": "Hello, {name}",
"itemCount": "{count, plural, =0 {No items} one {One item} other {# items}}"
}
Save this as app/translations/en.json. Create parallel files for other locales, such as app/translations/es.json and app/translations/fr.json, with the same keys but translated values.
2. Load translations using a server function
Use a server function to read the translation file from disk based on the requested locale, ensuring translations are loaded server-side during SSR and fetched on the client during navigation.
import { createServerFn } from "@tanstack/react-start";
import * as fs from "node:fs";
export const getMessages = createServerFn({ method: "GET" }).handler(
async ({ request }) => {
const url = new URL(request.url);
const locale = url.searchParams.get("locale") || "en";
const filePath = `app/translations/${locale}.json`;
const content = await fs.promises.readFile(filePath, "utf-8");
return JSON.parse(content);
},
);
This function reads the JSON file for the given locale and returns the parsed messages object. It runs only on the server, keeping file system access secure.
3. Create a helper to determine the user's locale
Define a small utility that extracts the locale from the request or falls back to a default, making it reusable across routes.
export function getLocaleFromRequest(request: Request): string {
const url = new URL(request.url);
const localeParam = url.searchParams.get("locale");
if (localeParam) return localeParam;
const acceptLanguage = request.headers.get("accept-language");
if (acceptLanguage) {
const match = acceptLanguage.split(",")[0].split("-")[0];
return match || "en";
}
return "en";
}
This function checks query parameters first, then the Accept-Language header, and defaults to English. It provides a single source of truth for locale detection.
4. Load messages in a route loader
Use the route loader to fetch messages for the current locale before rendering, making them available to the component tree.
import { createFileRoute } from "@tanstack/react-router";
import { getMessages, getLocaleFromRequest } from "../lib/i18n";
export const Route = createFileRoute("/")({
loader: async ({ context }) => {
const locale = getLocaleFromRequest(context.request);
const messages = await getMessages({ data: { locale } });
return { locale, messages };
},
component: HomePage,
});
function HomePage() {
const { locale, messages } = Route.useLoaderData();
return (
<div>
<p>{messages.welcome}</p>
</div>
);
}
The loader calls the server function to retrieve messages, and the component accesses them via useLoaderData. This pattern works for both SSR and client-side navigation.
5. Provide messages to react-intl
Wrap your component tree with IntlProvider, passing the loaded locale and messages so all descendant components can access translations.
import { IntlProvider } from "react-intl";
function HomePage() {
const { locale, messages } = Route.useLoaderData();
return (
<IntlProvider locale={locale} messages={messages}>
<AppContent />
</IntlProvider>
);
}
function AppContent() {
return (
<div>
<FormattedMessage id="welcome" />
</div>
);
}
IntlProvider makes the locale and messages available to all react-intl components and hooks. Components can now reference messages by key using FormattedMessage or useIntl, and the correct translation is rendered based on the loaded locale.
6. Reference messages by key in components
Use react-intl's FormattedMessage component or useIntl hook to display translated strings, referencing the keys defined in your JSON files.
import { FormattedMessage, useIntl } from "react-intl";
function UserGreeting({ name }: { name: string }) {
const intl = useIntl();
const title = intl.formatMessage({ id: "greeting" }, { name });
return (
<div>
<h1 title={title}>
<FormattedMessage id="greeting" values={{ name }} />
</h1>
<p>
<FormattedMessage id="itemCount" values={{ count: 5 }} />
</p>
</div>
);
}
FormattedMessage renders the translated string inline, while useIntl().formatMessage returns a string for use in attributes or JavaScript logic. Both accept values for interpolation and support ICU message syntax for plurals and formatting.