Add Translations (i18n)
How to make your feature work in multiple languages
Add Translations
TL;DR: Add keys to
messages/en.jsonandmessages/pl.json, useuseTranslations()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 automaticallyYou 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:
| Area | Examples |
|---|---|
| Navigation | Sidebar labels, breadcrumbs, page titles |
| DataTable | Column headers, filters, pagination ("Showing 1-10 of 50") |
| Forms | Labels, placeholders, validation messages |
| Dialogs | Confirm dialogs, toasts, error messages |
| Common actions | Save, 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:
| Key | Purpose | Example |
|---|---|---|
title | Page heading | "Invoices" |
subtitle | Below the heading | "Manage your invoices" |
addItem | Primary action button | "New Invoice" |
columns.* | DataTable column headers | columns.amount → "Amount" |
deleted | Toast after deletion | "Invoice deleted" |
deleteConfirmTitle | Confirmation dialog title | "Delete this invoice?" |
detail.* | Detail page strings | detail.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 stringsuseTranslations("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
oneandother. Polish needsone,few,many, andother. 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
| Task | What to do |
|---|---|
| Add a new translatable string | Add key to messages/en.json + messages/pl.json |
| Use translation in component | const t = useTranslations("namespace") then t("key") |
| Use shared strings (Save, Cancel) | const tCommon = useTranslations("common") |
| Translate DataTable columns | Factory function: createColumns(t, tCommon) |
| Add navigation label | Key in layout.sidebar.nav namespace |
| Dynamic values | t("greeting", { name }) with "Hello, {name}!" |
| Plurals | ICU format: "{count, plural, one {# item} other {# items}}" |
| Server component | const t = await getTranslations("namespace") |
| Dictionary value labels | resolveDictionaryLabel() — 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 translationMissing 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 automaticallyAdding a New Language
To add a third language (e.g., German):
- Create
messages/de.json— copyen.jsonand translate. You don't need to translate everything at once — missing keys fall back to English. - Add
"de"toappConfig.i18n.supportedLocalesinsrc/lib/config/index.ts - 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.
Related
- Feature Modules — i18n overview and other built-in features
- Add New Feature — Full feature creation guide
- Customize Navigation — Navigation labels and structure