Recipes

Add Translations (i18n)

How to make your feature work in multiple languages

Add Translations

TL;DR: Add keys to messages/en.json and messages/pl.json, use useTranslations() in components. Everything else is automatic.

How It Works (30-Second Version)

Each user picks their language in Settings → Profile. The app reads that preference from the database and loads the right translations. There's no /en/ or /pl/ in the URL — it's per-user, not per-page.

User picks "Polski" in profile

App loads messages/pl.json

All UI labels, buttons, columns switch to Polish

Missing translations? Falls back to English automatically

You don't need to configure routing, cookies, or middleware. Just add your text to the translation files and use the hook in your components.


What's Already Translated

Out of the box, the entire UI is translated:

AreaExamples
NavigationSidebar labels, breadcrumbs, page titles
DataTableColumn headers, filters, pagination ("Showing 1-10 of 50")
FormsLabels, placeholders, validation messages
DialogsConfirm dialogs, toasts, error messages
Common actionsSave, Cancel, Delete, Edit, Search...

You only need to add translations for your own features — the pages, columns, and labels specific to your business logic.


Step 1: Add Translation Keys

Open both files and add your feature's namespace:

// messages/en.json
{
  "invoices": {
    "title": "Invoices",
    "subtitle": "Manage your invoices and payments",
    "addInvoice": "New Invoice",
    "columns": {
      "number": "Invoice #",
      "client": "Client",
      "amount": "Amount",
      "dueDate": "Due Date",
      "status": "Status"
    },
    "deleted": "Invoice deleted",
    "deleteConfirmTitle": "Delete this invoice?",
    "deleteConfirmDescription": "This action cannot be undone."
  }
}
// messages/pl.json
{
  "invoices": {
    "title": "Faktury",
    "subtitle": "Zarządzaj fakturami i płatnościami",
    "addInvoice": "Nowa faktura",
    "columns": {
      "number": "Nr faktury",
      "client": "Klient",
      "amount": "Kwota",
      "dueDate": "Termin płatności",
      "status": "Status"
    },
    "deleted": "Faktura usunięta",
    "deleteConfirmTitle": "Usunąć tę fakturę?",
    "deleteConfirmDescription": "Tej operacji nie można cofnąć."
  }
}

Naming conventions:

KeyPurposeExample
titlePage heading"Invoices"
subtitleBelow the heading"Manage your invoices"
addItemPrimary action button"New Invoice"
columns.*DataTable column headerscolumns.amount → "Amount"
deletedToast after deletion"Invoice deleted"
deleteConfirmTitleConfirmation dialog title"Delete this invoice?"
detail.*Detail page stringsdetail.backLabel → "Back to invoices"
form.*Form labels (if different from columns)form.notes → "Internal Notes"

Step 2: Use in Page Components

// src/app/(dashboard)/invoices/page.tsx
"use client"

import { useTranslations } from "next-intl"

export default function InvoicesPage() {
  const t = useTranslations("invoices")
  const tCommon = useTranslations("common")

  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("subtitle")}</p>
      <button>{t("addInvoice")}</button>
      <button>{tCommon("cancel")}</button>
    </div>
  )
}

Two translation namespaces you'll use most:

  • useTranslations("yourFeature") — your feature's strings
  • useTranslations("common") — shared strings like Save, Cancel, Delete (already translated)

Step 3: Translate DataTable Columns

Columns are defined outside React components, so they can't call useTranslations() directly. Instead, pass the translation function as a parameter:

// src/app/(dashboard)/invoices/columns.tsx
import { ColumnDef } from "@tanstack/react-table"
import { DataTableColumnHeader } from "@/components/data-table"

type TranslationFunction = (key: string) => string

export function createColumns(
  t: TranslationFunction,
  tCommon: TranslationFunction
): ColumnDef<Invoice>[] {
  return [
    {
      accessorKey: "number",
      header: ({ column }) => (
        <DataTableColumnHeader column={column} title={t("columns.number")} />
      ),
    },
    {
      accessorKey: "clientName",
      header: ({ column }) => (
        <DataTableColumnHeader column={column} title={t("columns.client")} />
      ),
    },
    {
      accessorKey: "amount",
      header: ({ column }) => (
        <DataTableColumnHeader column={column} title={t("columns.amount")} />
      ),
    },
    {
      id: "actions",
      header: () => <span className="sr-only">{tCommon("actions")}</span>,
      // ...
    },
  ]
}

Then in your page:

const t = useTranslations("invoices")
const tCommon = useTranslations("common")
const columns = useMemo(() => createColumns(t, tCommon), [t, tCommon])

Step 4: Add Navigation Label

Navigation items use translation keys from the layout.sidebar.nav namespace:

// src/lib/config/navigation.ts
{
  label: "invoices",    // ← this is a translation key, not display text
  href: "/invoices",
  icon: FileText,
}
// messages/en.json
{
  "layout": {
    "sidebar": {
      "nav": {
        "invoices": "Invoices"
      }
    }
  }
}
// messages/pl.json
{
  "layout": {
    "sidebar": {
      "nav": {
        "invoices": "Faktury"
      }
    }
  }
}

The page <title> is also generated automatically from this label — you don't need to set it separately.


Interpolation and Plurals

For dynamic values, use curly braces:

{
  "welcome": "Hello, {name}!",
  "lastUpdated": "Last updated {date}"
}
t("welcome", { name: user.name })        // → "Hello, John!"
t("lastUpdated", { date: "March 15" })    // → "Last updated March 15"

For counts that change between singular and plural:

// English
{
  "itemCount": "{count, plural, one {# invoice} other {# invoices}}"
}
// Polish (has more plural forms)
{
  "itemCount": "{count, plural, one {# faktura} few {# faktury} many {# faktur} other {# faktur}}"
}
t("itemCount", { count: 1 })   // → "1 invoice" / "1 faktura"
t("itemCount", { count: 3 })   // → "3 invoices" / "3 faktury"
t("itemCount", { count: 5 })   // → "5 invoices" / "5 faktur"

Tip: English only needs one and other. Polish needs one, few, many, and other. You don't need to memorize the rules — just follow the pattern from existing translations.


Server Components

If you're in a server component (no "use client" at the top), use getTranslations instead:

import { getTranslations } from "next-intl/server"

export default async function InvoicePage() {
  const t = await getTranslations("invoices")
  return <h1>{t("title")}</h1>
}

Same keys, same files — just a different import.


Dictionary Labels (Lookup Values)

If your feature uses dictionary values (statuses, categories, etc.), those translations are handled differently — they live in the database, not in JSON files.

Each dictionary value has a labels field (JSONB) where admins can enter translations via Settings → Dictionaries:

{
  "en": "Active",
  "pl": "Aktywny"
}

To display the right label in your component:

import { useLocale } from "next-intl"
import { resolveDictionaryLabel } from "@/features/dictionaries"

const locale = useLocale()
const label = resolveDictionaryLabel(value.label, value.labels, locale)

This falls back to the default label field if no translation exists for the current locale.


Quick Reference

TaskWhat to do
Add a new translatable stringAdd key to messages/en.json + messages/pl.json
Use translation in componentconst t = useTranslations("namespace") then t("key")
Use shared strings (Save, Cancel)const tCommon = useTranslations("common")
Translate DataTable columnsFactory function: createColumns(t, tCommon)
Add navigation labelKey in layout.sidebar.nav namespace
Dynamic valuest("greeting", { name }) with "Hello, {name}!"
PluralsICU format: "{count, plural, one {# item} other {# items}}"
Server componentconst t = await getTranslations("namespace")
Dictionary value labelsresolveDictionaryLabel() — translations in DB, not JSON

For Your Prompts

You say...AI does...
"Add invoices feature with translations"Creates feature module + adds keys to both en.json and pl.json
"Translate the invoices page to Polish"Adds Polish keys to messages/pl.json matching the English structure
"Add a new column 'priority' to invoices table"Adds columns.priority to both translation files + column definition

Common Mistakes

Hardcoding text in components

<button>Save Changes</button>

Using translation keys

<button>{tCommon("save")}</button>

Using useTranslations in column definitions (it's outside React)

// This doesn't work — columns.tsx is not a component
const t = useTranslations("invoices")
export const columns = [...]

Factory function pattern

export function createColumns(t: TranslationFunction) {
  return [...]
}
// Called from inside a component where useTranslations works

Forgetting the Polish file

// Only added to en.json — Polish users see English keys

Always add to both files

messages/en.json  ← English text
messages/pl.json  ← Polish translation

Missing Polish keys won't crash the app — they'll fall back to English. But it looks unfinished.


Importing formatCurrency directly in components

import { formatCurrency } from "@/lib/utils/format"
// Uses the static default currency, ignores organization settings

Using the useFormatCurrency hook

const formatCurrency = useFormatCurrency()
// Reads the organization's currency automatically

Adding a New Language

To add a third language (e.g., German):

  1. Create messages/de.json — copy en.json and translate. You don't need to translate everything at once — missing keys fall back to English.
  2. Add "de" to appConfig.i18n.supportedLocales in src/lib/config/index.ts
  3. Add locale label in src/app/(dashboard)/settings/profile/page.tsx:
    const LOCALE_LABELS: Record<string, string> = {
      en: "English",
      pl: "Polski",
      de: "Deutsch",
    }

That's it. Users will see the new language option in their profile settings.

On this page