Tolgee Next.js Setup
This article explains how to integrate Tolgee into a Next.js app that relies on the App Router and next-intl
.
For further details, see Tolgee’s official guide.
Key improvements:
- Translations are fetched server-side from a CDN.
- The client reads static JSON files directly – no extra CDN calls.
- Local JSON fallback if Tolgee is unreachable.
- In-memory caching for better performance.
👉 Example repo: genaumann/next-tolgee-intl
👉 Demo page: next-tolgee-intl.vercel.app
Overview
Languages
- German (
de
) – default - English (
en
)
Tolgee namespaces
common
(default)app
Prerequisites
- Next.js project using the App Router
- Vercel CLI (optional)
- Tolgee project with two languages
- Enabled Tolgee CDN
- Tolgee API key (scopes
keys.view
,translations.view
)
next-intl routing
next-intl
drives locale routing.
de
is the default locale:
- German:
/contact
- English:
/en/contact
Installation
npm install next-intl @tolgee/react @tolgee/core @tolgee/web
npm install -D tsx dotenv-cli
Environment variables
Add these to .env.local
:
NEXT_PUBLIC_TOLGEE_API_KEY=<your-tolgee-api-key>
NEXT_PUBLIC_TOLGEE_API_URL=https://api.tolgee.io
TOLGEE_CDN_URL=https://cdn.tolg.ee/<id>
NEXT_PUBLIC
variables are exposed in the browser.
Ensure they are defined only in development.
vercel env add NEXT_PUBLIC_TOLGEE_API_KEY development
vercel env add NEXT_PUBLIC_TOLGEE_API_URL development
vercel env add TOLGEE_CDN_URL
Tolgee setup
Fetch plugin
Encapsulates the HTTP call that retrieves translations.
Falls back to local JSON files when the CDN is offline.
'use server'
type FetchI18nParams = {
isCdn: boolean
namespace?: string
language: string
}
export const fetchI18n = async ({
namespace,
language,
isCdn
}: FetchI18nParams): Promise<Record<string, string> | null> => {
const origin =
process.env.VERCEL_ENV === 'production'
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL || ''}`
: process.env.VERCEL_ENV === 'preview'
? `https://${process.env.VERCEL_BRANCH_URL}`
: 'http://localhost:3000'
const cdn = process.env.TOLGEE_CDN_URL
const url = isCdn
? `${cdn}/${namespace ? `${namespace}/` : ''}${language}.json`
: `${origin}/i18n/${namespace ? `${namespace}/` : ''}${language}.json`
const result = await fetch(url, {
next: {
revalidate: 3600, // cache for 1 hour
tags: ['i18n']
}
})
if (!result.ok) {
return null
}
return await result.json()
}
Plugin wrapper
Registers the custom Fetch plugin with Tolgee and re-exports it for both client and server.
import type {TolgeePlugin, BackendMiddleware} from '@tolgee/core'
import {fetchI18n} from './fetch'
type LoaderFn = (params: {
namespace?: string
language: string
}) => Promise<Record<string, string> | undefined> | Record<string, string>
interface BackendOptions {
loader: LoaderFn
}
export function CreateFunctionBackend({loader}: BackendOptions): TolgeePlugin {
return (tolgee, tools) => {
const backend: BackendMiddleware = {
async getRecord({namespace, language}) {
try {
const data = await loader({namespace, language})
if (!data || typeof data !== 'object') {
throw new Error('Loader function did not return a valid object')
}
return data
} catch (error) {
console.error('Error in Tolgee backend loader:', error)
throw error
}
}
}
tools.addBackend(backend)
return tolgee
}
}
type FetchTolgeeParams = {
namespace?: string
language: string
}
export const fetchTolgee = async ({
namespace,
language
}: FetchTolgeeParams): Promise<Record<string, string>> => {
const cdnData = await fetchI18n({
namespace,
language,
isCdn: true
})
if (!cdnData) {
return (
(await fetchI18n({
namespace,
language,
isCdn: false
})) || {}
)
}
return cdnData
}
Shared Tolgee base
Central factory that builds a configured Tolgee instance (API URL, languages, plugins).
Keeps client and server in sync.
import {DevTools, Tolgee, FormatSimple} from '@tolgee/web'
import {CreateFunctionBackend, fetchTolgee} from './plugin'
export enum LOCALES {
de = 'de',
en = 'en'
}
export const NAMESPACES = ['common', 'app']
export function TolgeeBase() {
const tolgee = Tolgee()
.use(FormatSimple()) // Super light formatter, which will enable you to pass variables into translations.
.use(DevTools()) // automatically omitted in production builds - useful in development
.use(CreateFunctionBackend({loader: fetchTolgee})) // custom backend loader
if (process.env.NODE_ENV === 'development') {
const apiKey = process.env.NEXT_PUBLIC_TOLGEE_API_KEY
const apiUrl = process.env.NEXT_PUBLIC_TOLGEE_API_URL
tolgee.updateDefaults({
// add api key and api url
apiKey,
apiUrl
})
}
return tolgee
}
Tolgee client
Initialises Tolgee on the browser side, creates the React context and enables in-memory caching.
'use client'
import {ReactNode, useEffect} from 'react'
import {
CachePublicRecord,
TolgeeProvider,
TolgeeStaticData
} from '@tolgee/react'
import {useRouter} from 'next/navigation'
import {TolgeeBase} from './base'
type Props = {
staticData: TolgeeStaticData | CachePublicRecord[]
language: string
children: ReactNode
}
const tolgee = TolgeeBase().init({
defaultNs: 'common'
})
export const TolgeeNextProvider = ({language, staticData, children}: Props) => {
const router = useRouter()
useEffect(() => {
const {unsubscribe} = tolgee.on('permanentChange', () => {
router.refresh()
})
return () => unsubscribe()
}, [tolgee, router])
return (
<TolgeeProvider tolgee={tolgee} ssr={{language, staticData}}>
{children}
</TolgeeProvider>
)
}
Tolgee server
Exports initTolgeeServer()
for server routes/RSC and a refined getTranslate(namespace?)
helper so you can pass namespaces directly.
import {getLocale} from 'next-intl/server'
import {
CombinedOptions,
createServerInstance,
DefaultParamType,
TranslationKey
} from '@tolgee/react/server'
import {TolgeeBase} from './base'
export const {getTolgee, T} = createServerInstance({
getLocale: getLocale,
createTolgee: async language => {
return TolgeeBase().init({
observerOptions: {
fullKeyEncode: true
},
defaultNs: 'common',
language
})
}
})
export const getTranslate = async (
ns?: string,
opts?: CombinedOptions<DefaultParamType>
) => {
const tolgee = await getTolgee()
if (ns) {
await tolgee.addActiveNs(ns)
}
return ns
? (key: TranslationKey, options?: CombinedOptions<DefaultParamType>) =>
tolgee.t(key, {ns, ...opts, ...options})
: (key: TranslationKey, options?: CombinedOptions<DefaultParamType>) =>
tolgee.t(key, {...opts, ...options})
}
Tolgee provider in the Root layout
Makes the Tolgee context available across the client tree and keeps keys in sync during runtime.
import {ReactNode} from 'react'
import {notFound} from 'next/navigation'
import {TolgeeNextProvider} from '@/lib/tolgee/client'
import {getTolgee} from '@/lib/tolgee/server'
import {LOCALES} from '@/lib/tolgee/base'
import '@/app/globals.css'
type Props = {
children: ReactNode
params: Promise<{locale: LOCALES}>
}
export default async function LocaleLayout({children, params}: Props) {
const {locale} = await params
if (!locale || !Object.keys(LOCALES).includes(locale)) {
notFound()
}
const tolgee = await getTolgee()
const records = await tolgee.loadRequired() // load default common namespace
return (
<html lang={locale}>
<body>
<TolgeeNextProvider language={locale} staticData={records}>
{children}
</TolgeeNextProvider>
</body>
</html>
)
}
next-intl setup
Request config
Decides which locale is active based on the incoming request.
messages
stays empty because Tolgee supplies translations.
import {getRequestConfig} from 'next-intl/server'
import {LOCALES} from '@/lib/tolgee/base'
export default getRequestConfig(async ({requestLocale}) => {
const locale = await requestLocale
return {
locale: locale || LOCALES.de,
messages: {}
}
})
Routing config
Lists available locales, default locale and optional redirect rules.
import {defineRouting} from 'next-intl/routing'
import {createNavigation} from 'next-intl/navigation'
import {LOCALES} from '@/lib/tolgee/base'
export const routing = defineRouting({
locales: Object.keys(LOCALES),
defaultLocale: LOCALES.de,
localePrefix: 'as-needed'
})
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing)
Middleware
Intercepts every request, extracts the locale segment (/en/...
) and injects it into next-intl
.
import createMiddleware from 'next-intl/middleware'
import {routing} from '@/i18n/routing'
export default createMiddleware(routing)
export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)']
}
Next.js config
Attaches next-intl
settings to the standard Next.js config and enables typed routes.
import {NextConfig} from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
const nextConfig: NextConfig = {}
const withNextIntl = createNextIntlPlugin()
export default withNextIntl(nextConfig)
Build script
CI script that downloads all translations from the Tolgee CDN into public/i18n/...
as an offline fallback.
import {LOCALES, NAMESPACES} from '@/lib/tolgee/base'
import fs from 'fs/promises'
import path from 'path'
const fetchTolgee = async () => {
const cdn = process.env.TOLGEE_CDN_URL
if (!cdn) {
throw new Error('TOLGEE_CDN_URL is not defined')
}
for (const locale of Object.values(LOCALES)) {
for (const ns of NAMESPACES) {
const localeDir = path.join(process.cwd(), 'public', 'i18n', ns)
await fs.mkdir(localeDir, {recursive: true})
const response = await fetch(`${cdn}/${ns}/${locale}.json`)
if (!response.ok) {
throw new Error(
`Failed to fetch ${ns} for ${locale}: ${response.statusText}`
)
}
const data = await response.json()
const filePath = path.join(localeDir, `${locale}.json`)
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
console.log(`[${locale}:${ns}]: Fetched and saved translations`)
}
}
}
fetchTolgee()
package.json
{
"scripts": {
"prebuild": "tsx scripts/fetchTolgee.ts"
}
}
Usage
Client side
Live example using the useTranslate()
hook from the React context.
'use client'
import {useTranslate} from '@tolgee/react'
export default function Page() {
const {t} = useTranslate()
// const {t} = useTranslate('app') // use the app namespace
return (
<div className="flex flex-col h-full items-center justify-center gap-4">
<h1 className="text-6xl">{t('welcome')}</h1>
</div>
)
}
Server side
Server component / RSC: translations are ready before streaming starts.
import {getTranslate} from '@/lib/tolgee/server'
export default async function Page() {
const t = await getTranslate()
// const t = await getTranslate('app') // use the app namespace
return (
<div className="flex flex-col h-full items-center justify-center gap-4">
<h1 className="text-6xl">{t('welcome')}</h1>
</div>
)
}