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 { awaitForTranslationOrFallbackToOriginal } from "i18n-keyless-node";
export default async function Page() {
const lang = "fr"; // resolve from cookies, headers, or URL
const title = await awaitForTranslationOrFallbackToOriginal("Welcome to Acme", lang);
return <h1>{title}</h1>;
}
Client side (any "use client" component):
"use client";
import { I18nKeylessText } from "i18n-keyless-react";
export function Hero() {
return <h1><I18nKeylessText>Welcome to Acme</I18nKeylessText></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 { awaitForTranslationOrFallbackToOriginal } 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 type { Lang } from "i18n-keyless-react";
import { useEffect } from "react";
let initialized = false;
export function I18nClientInit({ lang, apiKey }: { lang: Lang; apiKey: string }) {
if (typeof window !== "undefined" && !initialized) {
I18nKeyless.init({
API_KEY: apiKey,
storage: window.localStorage,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
initWithDefault: 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 { awaitForTranslationOrFallbackToOriginal } from "@/lib/i18n.server";
export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const heroTitle = await awaitForTranslationOrFallbackToOriginal("Welcome to Acme", lang);
const heroSubtitle = await awaitForTranslationOrFallbackToOriginal("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 awaitForTranslationOrFallbackToOriginal. Client components use <I18nKeylessText>. They coexist:
// app/[lang]/page.tsx (server component)
import { awaitForTranslationOrFallbackToOriginal } 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 awaitForTranslationOrFallbackToOriginal("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 { I18nKeylessText } 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 awaitForTranslationOrFallbackToOriginal("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 { awaitForTranslationOrFallbackToOriginal } from "i18n-keyless-node";
import type { GetServerSideProps } from "next";
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
const lang = locale ?? "en";
return {
props: {
title: await awaitForTranslationOrFallbackToOriginal("Welcome to Acme", lang),
},
};
};
Step 8: Localize metadata (titles, descriptions)
App Router metadata API:
// app/[lang]/page.tsx
import { awaitForTranslationOrFallbackToOriginal } 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 awaitForTranslationOrFallbackToOriginal("Acme — multilingual SaaS in 5 minutes", lang),
description: await awaitForTranslationOrFallbackToOriginal("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 awaitForTranslationOrFallbackToOriginal 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), use awaitForTranslationOrThrow instead of the fallback variant — it runs during build and rejects on a failed request, so a failed translation fails the build instead of shipping the source text. 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 <I18nKeylessText> (client) or awaitForTranslationOrFallbackToOriginal (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 (awaitForTranslationOrFallbackToOriginal) in server components and i18n-keyless-react (<I18nKeylessText>) in client components.
Does it support next export (static site export)?
Yes. Use awaitForTranslationOrThrow for the static-export build — it runs at build time and rejects on failure, so a failed translation fails the build instead of shipping the source text, with the resolved values baked 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 awaitForTranslationOrFallbackToOriginal is just fetch + cache.
What about Sentry / observability for translation errors?
It depends which function you call. awaitForTranslationOrFallbackToOriginal never rejects — on a failed request it returns the source string and logs the failure with console.error, so no try/catch is needed; wire Sentry to that console.error if you want visibility. awaitForTranslationOrThrow rejects instead, so you get the error directly — wrap it in try/catch and report it to Sentry yourself, or leave it unhandled on purpose (a script or build step) and let it crash.
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.