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>
Public environment variables

NEXT_PUBLIC variables are exposed in the browser.
Ensure they are defined only in development.

Vercel CLI
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.

fetch.ts
fetch.ts
libtolgeefetch.ts
123456789101112131415161718192021222324252627282930313233343536373839
'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.

plugin.ts
plugin.ts
libtolgeeplugin.ts
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
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.

base.ts
base.ts
libtolgeebase.ts
1234567891011121314151617181920212223242526272829
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.

client.tsx
client.tsx
libtolgeeclient.tsx
1234567891011121314151617181920212223242526272829303132333435363738
'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.

server.ts
server.ts
libtolgeeserver.ts
12345678910111213141516171819202122232425262728293031323334353637
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.

layout.tsx
layout.tsx
app[locale]layout.tsx
12345678910111213141516171819202122232425262728293031
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.

request.ts
request.ts
i18nrequest.ts
123456789101112
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.

routing.ts
routing.ts
i18nrouting.ts
12345678910111213
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.

middleware.ts
middleware.ts
middleware.ts
12345678910
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.

next.config.ts
next.config.ts
next.config.ts
123456789
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.

fetchTolgee.ts
fetchTolgee.ts
scriptsfetchTolgee.ts
12345678910111213141516171819202122232425262728293031
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.

page.tsx
page.tsx
app[locale]page.tsx
12345678910111213
'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.

page.tsx
page.tsx
app[locale]page.tsx
1234567891011
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>
  )
}