Next.js, next-intl and translations in Sleekdocs
With the App Router, a Next.js app can fetch its texts on the server from the files Sleekdocs Translate deploys. Pages are prerendered per language, and new texts reach them within minutes, without a new build.
What you need
- A Next.js app with the App Router. This guide uses Next.js 16 with Cache Components, which
create-next-appturns on, and next-intl 4. - A Sleekdocs Translate project with at least one language deployed to an environment.
Sleekdocs deploys each language to its own file on the CDN, one per environment:
https://cdn.sleekdocs.com/<project>/<environment>/<language>.json
The environment is dev, test or prod, and the language is its code, such as sv. Sleekdocs Translate shows the address of your own project when you deploy.
Create the app and install next-intl
Skip the first two lines if you already have an app.
npx create-next-app@latest my-app --ts --app --src-dir
cd my-app
npm install next-intl
One address per environment
Next.js reads .env.development for next dev and .env.production for next build. The address is only used on the server, so it needs no NEXT_PUBLIC_ prefix:
# .env.development
TRANSLATIONS_URL=https://cdn.sleekdocs.com/<project>/dev/
# .env.production
TRANSLATIONS_URL=https://cdn.sleekdocs.com/<project>/prod/
Load the texts
next-intl asks src/i18n/request.ts for each request's language and texts. The texts are fetched in a cached function, so the CDN is asked again at most every few minutes:
import { cacheLife } from 'next/cache';
import { getRequestConfig } from 'next-intl/server';
export const languages = ['en', 'sv'];
const primary = 'en';
async function loadTexts(lang: string): Promise<Record<string, string>> {
'use cache';
cacheLife('minutes');
const response = await fetch(`${process.env.TRANSLATIONS_URL}${lang}.json`);
if (!response.ok) {
throw new Error(`No texts for ${lang}: ${response.status}`);
}
return response.json();
}
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = requested && languages.includes(requested) ? requested : primary;
const messages = locale === primary
? await loadTexts(primary)
: { ...(await loadTexts(primary)), ...(await loadTexts(locale)) };
return { locale, messages };
});
A deployed file only holds the texts that are translated, and next-intl has no fallback language of its own. Laying the language's texts over the primary language's, as above, shows a missing Swedish text in English instead of as its key.
Add the plugin in next.config.ts:
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default createNextIntlPlugin()(nextConfig);
One route per language
Move layout.tsx and page.tsx into src/app/[locale]/, so that /en and /sv are pages of their own. The layout lists the languages to prerender and refuses others:
// src/app/[locale]/layout.tsx
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import { languages } from '@/i18n/request';
export function generateStaticParams() {
return languages.map((locale) => ({ locale }));
}
export default async function Layout({ children, params }: LayoutProps<'/[locale]'>) {
const { locale } = await params;
if (!languages.includes(locale)) {
notFound();
}
setRequestLocale(locale);
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
Use the texts
// src/app/[locale]/page.tsx
import Link from 'next/link';
import { getTranslations, setRequestLocale } from 'next-intl/server';
export default async function Page({ params }: PageProps<'/[locale]'>) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations();
return (
<main>
<h1>{t('title')}</h1>
<p>{t('cartItems', { count: 3 })}</p>
<Link href="/en">English</Link> <Link href="/sv">Svenska</Link>
</main>
);
}
The build prerenders /en and /sv, and an unknown language gives a 404. In client components, wrap them in next-intl's NextIntlClientProvider and use useTranslations.
How texts are written
- next-intl reads texts as ICU messages: a placeholder is
{count}, and{{count}}, the i18next style, is an error that shows the key instead. - next-intl treats a dot in a key as a step into a nested object, and doesn't find a flat key such as
"checkout.title". If your keys contain dots, set the project's File format to JSON - Nested; Flat or nested JSON for translation files? explains the difference.
New texts reach the site without a build
When a translator publishes a language and deploys it, the cached texts expire within minutes, and the next request renders the pages again with the new file. Deploy translations without rebuilding your app describes publishing, deploying and rolling back.
Try it
Create a team, import the JSON files your app already has, and deploy them to development. Every new team gets 14 days of Pro. Get started, or read how Sleekdocs Translate works.