How to add i18n to a Next.js app (App Router and Pages Router)
Internationalizing a Next.js app in 2026 has more options than it used to. The official Next.js i18n routing was deprecated in favor of "build it yourself" patterns. next-intl is the most popular library purpose-built for the App Router. react-i18next works but adds friction.
This guide shows the keyless alternative — i18n-keyless-node for server components and i18n-keyless-react for client components, with a working setup for both App Router and Pages Router.
TL;DR
npm install i18n-keyless-node i18n-keyless-react
Server side (App Router):
// app/page.tsx
import { awaitForTranslation } from "i18n-keyless-node";
export default async function Page() {
const lang = "fr"; // resolve from cookies, headers, or URL
const title = await awaitForTranslation("Welcome to Acme", lang);
return <h1>{title}</h1>;
}
Client side (any "use client" component):
"use client";
import { I18nKeyless } from "i18n-keyless-react";
export function Hero() {
return <h1><I18nKeyless>Welcome to Acme</I18nKeyless></h1>;
}
Same source-string-as-key model on both sides. Server components await translations; client components stream them.
Prerequisites
- Next.js 14+ (App Router examples assume
app/directory; Pages Router covered in section below). - An API key from i18n-keyless.com.
Step 1: Install both SDKs
npm install i18n-keyless-node i18n-keyless-react
You need both because Next.js renders on both server and client. Server components use i18n-keyless-node; client components use i18n-keyless-react.
Step 2: Initialize on the server
Create a server-side init file. Next.js calls server modules once per request scope:
// lib/i18n.server.ts
import * as I18nKeyless from "i18n-keyless-node";
I18nKeyless.init({
API_KEY: process.env.I18N_KEYLESS_API_KEY!,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
},
});
export { awaitForTranslation } from "i18n-keyless-node";
Add to .env.local:
I18N_KEYLESS_API_KEY=your_api_key_here
Step 3: Initialize on the client
// app/i18n-client-init.tsx
"use client";
import * as I18nKeyless from "i18n-keyless-react";
import { useEffect } from "react";
let initialized = false;
export function I18nClientInit({ lang, apiKey }: { lang: string; apiKey: string }) {
if (typeof window !== "undefined" && !initialized) {
I18nKeyless.init({
API_KEY: apiKey,
storage: window.localStorage,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
initial: lang,
},
});
initialized = true;
}
return null;
}
Step 4: App Router setup with locale-prefixed URLs
Use Next.js dynamic segments for locale-prefixed routing:
app/
[lang]/
layout.tsx
page.tsx
about/
page.tsx
// app/[lang]/layout.tsx
import { I18nClientInit } from "../i18n-client-init";
export const metadata = { title: "Acme" };
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
return (
<html lang={lang}>
<body>
<I18nClientInit lang={lang} apiKey={process.env.NEXT_PUBLIC_I18N_KEYLESS_API_KEY!} />
{children}
</body>
</html>
);
}
export function generateStaticParams() {
return [{ lang: "en" }, { lang: "fr" }, { lang: "de" }, { lang: "es" }];
}
// app/[lang]/page.tsx
import { awaitForTranslation } from "@/lib/i18n.server";
export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const heroTitle = await awaitForTranslation("Welcome to Acme", lang);
const heroSubtitle = await awaitForTranslation("Ship multilingual apps in 5 minutes.", lang);
return (
<main>
<h1>{heroTitle}</h1>
<p>{heroSubtitle}</p>
</main>
);
}
URLs become /en/, /fr/, /de/, /es/. Search engines index each locale separately. SSR returns fully-translated HTML — no client-side flash of source-language content.
Step 5: Mix server and client components
Server components use awaitForTranslation. Client components use <I18nKeyless>. They coexist:
// app/[lang]/page.tsx (server component)
import { awaitForTranslation } from "@/lib/i18n.server";
import { LanguageSwitcher } from "./language-switcher"; // client component
export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const title = await awaitForTranslation("Welcome to Acme", lang);
return (
<main>
<h1>{title}</h1>
<LanguageSwitcher /> {/* client interactivity */}
</main>
);
}
// app/[lang]/language-switcher.tsx (client component)
"use client";
import { useRouter, usePathname, useParams } from "next/navigation";
import { I18nKeyless } from "i18n-keyless-react";
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const { lang } = useParams<{ lang: string }>();
function setLanguage(newLang: string) {
const newPath = pathname.replace(`/${lang}`, `/${newLang}`);
router.push(newPath);
}
return (
<select value={lang} onChange={(e) => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
);
}
Step 6: Detect locale from request headers (alternative to URL prefixing)
If you don't want locale-prefixed URLs and prefer to detect from Accept-Language:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const SUPPORTED = ["en", "fr", "de", "es"];
export function middleware(request: NextRequest) {
const cookieLang = request.cookies.get("NEXT_LOCALE")?.value;
const headerLang = request.headers.get("accept-language")?.split(",")[0]?.split("-")[0];
const lang = cookieLang ?? (headerLang && SUPPORTED.includes(headerLang) ? headerLang : "en");
const response = NextResponse.next();
response.headers.set("x-lang", lang);
return response;
}
Then read the header in your server components:
import { headers } from "next/headers";
const lang = (await headers()).get("x-lang") ?? "en";
const title = await awaitForTranslation("Welcome to Acme", lang);
URL-prefixed routing is generally better for SEO; header-based detection is simpler but less crawlable.
Step 7: Pages Router setup
If you're still on Pages Router:
// pages/_app.tsx
import * as I18nKeyless from "i18n-keyless-react";
import { useEffect } from "react";
import type { AppProps } from "next/app";
let initialized = false;
export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
if (!initialized) {
I18nKeyless.init({
API_KEY: process.env.NEXT_PUBLIC_I18N_KEYLESS_API_KEY!,
storage: window.localStorage,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
},
});
initialized = true;
}
}, []);
return <Component {...pageProps} />;
}
For SSR with getServerSideProps, use i18n-keyless-node:
// pages/index.tsx
import { awaitForTranslation } from "i18n-keyless-node";
import type { GetServerSideProps } from "next";
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
const lang = locale ?? "en";
return {
props: {
title: await awaitForTranslation("Welcome to Acme", lang),
},
};
};
Step 8: Localize metadata (titles, descriptions)
App Router metadata API:
// app/[lang]/page.tsx
import { awaitForTranslation } from "@/lib/i18n.server";
import type { Metadata } from "next";
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
return {
title: await awaitForTranslation("Acme — multilingual SaaS in 5 minutes", lang),
description: await awaitForTranslation("The fastest way to localize your product.", lang),
alternates: {
languages: {
en: "/en",
fr: "/fr",
de: "/de",
es: "/es",
},
},
};
}
The alternates.languages field generates <link rel="alternate" hreflang="..." /> tags — important for international SEO.
Step 9: Sitemap with locale-aware URLs
// app/sitemap.ts
import type { MetadataRoute } from "next";
const SUPPORTED = ["en", "fr", "de", "es"];
export default function sitemap(): MetadataRoute.Sitemap {
const routes = ["/", "/about", "/pricing"];
return routes.flatMap((route) =>
SUPPORTED.map((lang) => ({
url: `https://yourdomain.com/${lang}${route}`,
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
SUPPORTED.map((l) => [l, `https://yourdomain.com/${l}${route}`])
),
},
}))
);
}
Common gotchas
Streaming translations and Suspense
Server components with awaitForTranslation block until the translation resolves (or falls back to the source string after timeout). For Suspense-friendly streaming, wrap the translation-heavy section in <Suspense fallback={...}> so the rest of the page can render while translations resolve.
Build-time translation generation
If you want translations resolved at build time (output: 'export' static export), awaitForTranslation runs during build and the resolved values are baked in. The first build for a new locale is slower because the SDK requests translations from the backend. Subsequent builds use cached translations.
Hydration mismatches
If your client-side initial state doesn't match what the server rendered, you'll see hydration warnings. To avoid: always pass the resolved language as a prop from the server to the client init, rather than detecting on both sides independently.
API key exposure
The NEXT_PUBLIC_I18N_KEYLESS_API_KEY is intentionally public — it's the bearer token for the SDK. Keep your I18N_KEYLESS_PRIVATE_KEY (admin / management key) server-only.
next-intl migration
If you're moving from next-intl to keyless, the App Router routing patterns above translate well. The key changes: replace useTranslations with <I18nKeyless> (client) or awaitForTranslation (server), drop the messages prop loading, remove your messages/ directory.
When to use a different approach
- ❌ Need ICU MessageFormat plural rules → use
next-intlorreact-intl. - ❌ Have human translators in workflow → use
next-intl+ Lokalise / Crowdin. - ❌ Need fully static export with no runtime translation backend → use
next-intlwith bundled JSON files.
For everyone else, keyless is faster on Next.js too.
FAQ
Does i18n-keyless work with App Router and React Server Components?
Yes. Use i18n-keyless-node (awaitForTranslation) in server components and i18n-keyless-react (<I18nKeyless>) in client components.
Does it support next export (static site export)?
Yes. awaitForTranslation runs at build time during static export, embedding translations into the generated HTML.
How does it compare to next-intl?
next-intl is excellent if you want a key-based workflow with full ICU support and Next.js-native integration. i18n-keyless is faster to set up and removes key management entirely. Different trade-offs.
Can I use it with Vercel Edge Runtime?
Server-side translations work in Edge functions because awaitForTranslation is just fetch + cache.
What about Sentry / observability for translation errors?
Translation requests are wrapped in try/catch internally. If the backend is unreachable, the SDK returns the source string. You can wire Sentry to your awaitForTranslation calls if you want explicit visibility.
Next steps
- Get an API key: i18n-keyless.com.
- Quick setup: Setup reference.
- Compare options: Best i18next alternatives in 2026.
- Other frameworks: React · React Native · Node.js.
- The complete guide: The complete guide to keyless i18n.