Build a landing page with Vite, React Router 7, and i18next
What You'll Learn
- How to set up a React project using Vite and React Router 7
- How to add internationalization (i18n) with i18next
- How to structure your app so users can switch languages via the URL
- How to translate page content and meta tags dynamically
Prerequisites
- Basic knowledge of JavaScript and React
- Node.js and npm installed on your computer
Introduction
Modern web applications often need to support multiple languages. While frameworks like Next.js and Vercel make this easier, it's valuable to understand how to set up internationalization (i18n) yourself using React Router 7, Vite, and i18next. This guide will walk you through building a simple landing page that supports multiple languages, with clear explanations for each step.
Step 1: Create the project
We'll start by creating a new React project using the official React Router scaffolding tool. This gives us a clean setup with Vite and React Router 7.
npx create-react-router@latest my-react-router-app
cd my-react-router-app
What’s happening here?
- The first command creates a new React project in a folder called my-react-router-app.
- The second command moves you into that folder so you can start working on your app.
Step 2: Install i18n dependencies
To add translation support, we need to install a few packages:
npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend remix-i18next isbot
What are these packages?
- i18next: The core internationalization library.
- react-i18next: React bindings for i18next.
- i18next-browser-languagedetector: Detects the user's language in the browser.
- i18next-http-backend: Loads translation files over HTTP.
- remix-i18next: Integration for Remix/React Router apps.
- isbot: Detects if a request is from a search engine bot (for SEO).
Step 3: Locale-based routing
We want our landing page to support URLs like:
- https://mywebsite.com/en
- https://mywebsite.com/fr
- https://mywebsite.com/ (defaults to English or your chosen default)
Why do this?
- This approach makes it easy for users (and search engines) to know which language is being used.
- It allows for easy sharing of links in different languages.
How to set it up:
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";
export default [route(":locale?", "./routes/home.tsx")] satisfies RouteConfig;
Explanation:
- :locale? is a dynamic route parameter. The ? means it's optional, so both / and /:locale will work.
- This makes the locale (like en or fr) available in your components, so you can load the right translations.
Step 4: Set up translation files
We need to create translation files for each language we want to support.
File structure:
public/
locales/
en/
common.json
es/
common.json
Example english translation (public/locales/en/common.json):
{
"greeting": "Hello",
"meta": {
"title": "Welcome to my website",
"description": "This is a Remix + Vite + i18next example website."
}
}
}
Example spanish translation (public/locales/es/common.json):
{
"greeting": "Hola",
"meta": {
"title": "Bienvenido a mi sitio web",
"description": "Este es un sitio web de ejemplo Remix + Vite + i18next."
}
}
Why this structure?
- Each language has its own folder.
- Each folder contains a common.json file with all the translations for that language.
Step 5: Configure i18next
Create a file called i18n.ts in your app directory:
// app/i18n.ts
export default {
supportedLngs: ["en", "es"],
fallbackLng: "en",
defaultNS: "common",
};
Explanation:
- supportedLngs: The languages your app supports.
- fallbackLng: The default language if none is specified.
- defaultNS: The default "namespace" (translation file) to use.
Step 6: i18n server setup for translation
Now we'll configure i18next on the server side to load translations from the file system. This ensures the correct language is loaded based on the URL.
// app/i18n.server.ts
import Backend from "i18next-fs-backend/cjs";
import { resolve } from "node:path";
import { URL } from "node:url";
import { RemixI18Next } from "remix-i18next/server";
import i18n from "~/i18n";
let i18next = new RemixI18Next({
detection: {
supportedLanguages: i18n.supportedLngs,
fallbackLanguage: i18n.fallbackLng,
async findLocale*(request)* {
const url = new URL(request.url);
let locale = url.pathname.split("/").at(1);
return locale || "en";
},
},
i18next: {
...i18n,
backend: {
loadPath: resolve("./public/locales/{{lng}}/{{ns}}.json"),
},
},
plugins: [Backend],
});
export default i18next;
What’s happening here?
- The server checks the URL to find the locale (language code).
- It loads the correct translation file from the public/locales folder.
- If no locale is found, it defaults to English.
Step 7: Server setup for translation
This step sets up the server to use the correct translations when rendering pages. This is important for SEO and for users who don't have JavaScript enabled.
// app/entry.server.tsx
import { createReadableStreamFromReadable } from "@react-router/node";
import { createInstance } from "i18next";
import Backend from "i18next-fs-backend/cjs";
import { isbot } from "isbot";
import { resolve as resolvePath } from "node:path";
import { PassThrough } from "node:stream";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
import { I18nextProvider, initReactI18next } from "react-i18next";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import i18n from "./i18n";
import i18next from "./i18n.server";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext
) {
return new Promise(async (resolve, reject) => {
let i18nextInstance = createInstance();
let lng = await i18next.getLocale(request);
let namespaces = i18next.getRouteNamespaces(routerContext);
await i18nextInstance
.use(initReactI18next)
.use(Backend)
.init({
...i18n,
lng,
ns: namespaces,
backend: {
addPath: resolvePath("./public/locales"),
loadPath: resolvePath("./public/locales/{{lng}}/{{ns}}.json"),
},
});
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
const { pipe, abort } = renderToPipeableStream(
<I18nextProvider i18n={i18nextInstance}>
<ServerRouter context={routerContext} url={request.url} />,
</I18nextProvider>,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, streamTimeout + 1000);
});
}
Why is this important?
- This ensures the correct language is used when the server renders the page.
- It helps with SEO and accessibility.
Step 8: Update root.tsx for language support
This step ensures the app uses the correct language on every page load.
// app/root.tsx
import { useTranslation } from "react-i18next";
import {
Links,
Meta,
Scripts,
ScrollRestoration,
useLoaderData,
} from "react-router";
import { useChangeLanguage } from "remix-i18next/react";
import type { Route } from "./+types/root";
import i18next from "./i18n.server";
import "./app.css";
export async function loader({ request }: Route.LoaderArgs) {
let locale = await i18next.getLocale(request);
return Response.json({ locale });
}
export const links: Route.LinksFunction = () => [];
export function Layout({ children }: { children: React.ReactNode }) {
const { locale } = useLoaderData<typeof loader>();
let { i18n } = useTranslation();
// This hook will change the i18n instance language to the current locale
useChangeLanguage(locale);
return (
<html lang={locale} dir={i18n.dir()}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
What’s happening here?
- The loader function gets the current locale from the request.
- The useChangeLanguage hook updates the language in the app.
- The <html lang={locale}> tag helps browsers and search engines know which language is being used.
Step 9: Client-Side Translation Configuration
This file is responsible for hydrating the client-side app, initializing i18next, and setting up language detection.
// app/entry.client.tsx
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { BrowserRouter } from "react-router";
import { getInitialNamespaces } from "remix-i18next/client";
import i18n from "./i18n";
async function hydrate() {
await i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(Backend)
.init({
...i18n,
ns: getInitialNamespaces(),
backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" },
detection: {
order: ["htmlTag"],
caches: [],
},
});
startTransition(() => {
hydrateRoot(
document,
<I18nextProvider i18n={i18next}>
<StrictMode>
<BrowserRouter />
</StrictMode>
</I18nextProvider>
);
});
}
if (window.requestIdleCallback) {
window.requestIdleCallback(hydrate);
} else {
window.setTimeout(hydrate, 1);
}
What’s happening here?
- The client-side app initializes i18next and loads the correct translations.
- Language detection uses the <html lang> attribute set by the server.
- The app is hydrated (made interactive) once translations are ready.
Step 10: Handling meta tags and translations
To dynamically translate meta tags like the title and description, we use the meta and loader functions in React Router.
// app/routes/home.tsx
import { useTranslation } from "react-i18next";
import { Route } from "react-router";
import i18next from "~/i18n.server";
export function meta({ data }: Route.MetaArgs) {
return [
{ title: data?.title },
{
name: "description",
content: data?.description,
},
];
}
export async function loader({ request }: Route.LoaderArgs) {
let t = await i18next.getFixedT(request, "common", {});
return {
title: t("meta.title"),
description: t("meta.description"),
};
}
export default function Home() {
const { t } = useTranslation();
return (
<div>
<h1>{t("greeting")}</h1>
</div>
);
}
Explanation:
- The meta function sets the page title and description using translated values.
- The loader function fetches the translations for the current language.
- The Home component displays the translated greeting.
Conclusion
In this article, we've walked through the process of building a landing page using Vite, React Router 7, and i18next for multilingual support. By configuring i18next both server-side and client-side, managing routes with locales, and dynamically translating meta tags, we created a flexible, multilingual app that can scale as needed.
Next steps:
- Try adding more languages by creating new translation files.
- Add a language switcher component for users to change languages easily.
- Explore more advanced i18next features like plurals and formatting.
Feel free to adapt this approach to your own projects for a smooth developer experience and robust internationalization support.