Back to blog
Terminal output showing a translation script writing de.json and fr.json, alongside a folder tree and a browser rendering the German locale route.

How to translate a Next.js app with PolyLingo in under 30 minutes

By Robert

How to translate a Next.js app with PolyLingo in under 30 minutes

By the end of this tutorial you'll have a working multilingual Next.js App Router project: strings extracted into messages/en.json, translated locale files for every language you need, next-intl serving the right file per route, and a single Node script you can re-run whenever your content changes.

No translation platform to sign up for. No per-language flat fees. One API call handles all your target languages at once.

What you'll need:

  • A Next.js project using the App Router (Next.js 14 or 15)
  • Node.js 18 or later
  • A free PolyLingo account and API key

Step 1: Get your PolyLingo API key (5 minutes)

Create a free account at usepolylingo.com. The free tier includes 100,000 tokens per month, which is enough to translate a medium-sized locale file into 10+ languages several times over.

Once you're in, go to API keys in the dashboard and create a key. You'll only see the full value once, so copy it immediately.

Add it to your project as an environment variable. Never commit it to version control and never expose it in client-side code:

# .env.local
POLYLINGO_API_KEY="pl_your_key_here"

Verify the API is reachable before going further:

curl -sS "https://api.usepolylingo.com/v1/health"

You should get back a small JSON payload with "status": "ok".


Step 2: Install next-intl and set up routing (10 minutes)

Install the library:

npm install next-intl

Create an i18n.ts file at your project root. This tells next-intl which locales you support and how to load the right message file for each request:

// i18n.ts
import { getRequestConfig } from 'next-intl/server'
 
export const locales = ['en', 'de', 'fr'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'en'
 
export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale
  if (!locale || !locales.includes(locale as Locale)) {
    locale = defaultLocale
  }
  return {
    locale,
    messages: (await import(`./messages/${locale}.json`)).default,
  }
})

Add middleware to prefix routes with the locale:

// middleware.ts
import createMiddleware from 'next-intl/middleware'
import { locales, defaultLocale } from './i18n'
 
export default createMiddleware({
  locales: [...locales],
  defaultLocale,
  localePrefix: 'as-needed',
})
 
export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
}

Move your page files under app/[locale]/. Update your root layout to receive the locale param and wrap children with NextIntlClientProvider:

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
 
export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}) {
  const { locale } = await params
  const messages = await getMessages()
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

Step 3: Extract your strings into a JSON message file (10 minutes)

Create a messages/ folder at your project root. Add an en.json file with your source strings. next-intl uses a nested key structure:

{
  "Home": {
    "title": "Welcome",
    "cta": "Get started"
  }
}

Update your pages to use useTranslations instead of hardcoded strings:

// app/[locale]/page.tsx
import { useTranslations } from 'next-intl'
 
export default function HomePage() {
  const t = useTranslations('Home')
  return (
    <main>
      <h1>{t('title')}</h1>
      <button type="button">{t('cta')}</button>
    </main>
  )
}

Now write the translation script. This reads messages/en.json, sends it to the PolyLingo API with format: "json", and writes one output file per target locale. The format: "json" flag tells the API to preserve the key structure and translate only string values — nested keys, arrays, and non-string types all come back untouched.

// scripts/translate-messages.mjs
// Run with: node scripts/translate-messages.mjs
 
import fs from 'node:fs'
import path from 'node:path'
 
const API_KEY = process.env.POLYLINGO_API_KEY
const API_URL = (process.env.POLYLINGO_API_URL || 'https://api.usepolylingo.com/v1').replace(/\/$/, '')
const TARGETS = ['de', 'fr'] // extend this array to add more locales
 
const enPath = path.join('messages', 'en.json')
const en = JSON.parse(fs.readFileSync(enPath, 'utf8'))
 
const res = await fetch(`${API_URL}/translate`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    content: JSON.stringify(en),
    format: 'json',
    targets: TARGETS,
    model: 'standard',
  }),
})
 
if (!res.ok) {
  const err = await res.text()
  throw new Error(`PolyLingo ${res.status}: ${err}`)
}
 
const { translations } = await res.json()
 
for (const locale of TARGETS) {
  const out = path.join('messages', `${locale}.json`)
  fs.writeFileSync(out, JSON.stringify(JSON.parse(translations[locale]), null, 2) + '\n')
  console.log('Wrote', out)
}

Run it:

node scripts/translate-messages.mjs

You should see output like:

Wrote messages/de.json
Wrote messages/fr.json

Open those files and check them. Your keys will be identical to en.json. Only the string values will have changed.


Step 4: Smoke-test the routes (5 minutes)

Start your dev server:

npm run dev

Visit http://localhost:3000 and http://localhost:3000/de. The heading and button should render in English and German respectively. Add more locales by extending the TARGETS array in the script and the locales array in i18n.ts, then re-run the script.

Check your token usage in the PolyLingo dashboard under Usage. For a small locale file translated into two languages you'll have used a few hundred tokens out of your monthly grant.


Where to go from here

Add more locales. The script sends one request regardless of how many entries are in TARGETS. Adding Japanese, Spanish, and Arabic costs one API call, not three.

Wire it into CI. Add POLYLINGO_API_KEY as a repository secret in GitHub Actions and run the script as part of your build pipeline. Your locale files stay in sync automatically whenever en.json changes.

Translate other formats. The same script pattern works for Markdown documentation pages (format: "markdown") and HTML email templates (format: "html"). The API preserves structure in all cases.

Use the batch endpoint for larger projects. If you have multiple separate JSON files (one per feature area, for example), POST /translate/batch accepts up to 100 items in a single request, each with its own id and format.


Try it free

PolyLingo's free tier includes 100,000 tokens per month. No credit card required.

curl -sS -X POST "https://api.usepolylingo.com/v1/translate" \
  -H "Authorization: Bearer $POLYLINGO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "{\"Home\":{\"title\":\"Welcome\",\"cta\":\"Get started\"}}",
    "format": "json",
    "targets": ["de", "fr", "es", "ja"]
  }'

Get your API key