This guide covers how BlueCollar handles internationalization, how to contribute translations for the app UI and README files, and how to add support for a new locale.
- Check open i18n issues — someone may already be working on your language.
- Fork the repo and create a branch:
git checkout -b i18n/add-{language} - Translate the UI messages (see Adding a New Language).
- Optionally translate the README (see README Translations).
- Open a PR with title format:
i18n: add {language} ({locale}) translations
BlueCollar uses next-intl for all i18n functionality in the packages/app Next.js frontend.
packages/app/next.config.mjs wraps the Next.js config with the next-intl plugin:
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
export default withNextIntl(nextConfig)The plugin automatically picks up packages/app/src/i18n.ts as the request config entry point.
packages/app/src/i18n.ts loads the correct message file for each request based on the active locale:
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default
}))packages/app/src/middleware.ts handles locale detection and URL prefixing:
import createMiddleware from 'next-intl/middleware'
const intlMiddleware = createMiddleware({
locales: ['en', 'fr', 'es'],
defaultLocale: 'en',
localePrefix: 'always',
})The localePrefix: 'always' strategy means every URL includes the locale segment. A request to /workers will redirect to /en/workers.
/en/workers → English workers page
/fr/workers → French workers page
/es/dashboard → Spanish dashboard
/en/auth/login → English login page
Translation files live in packages/app/src/messages/ as JSON files, one per locale.
packages/app/src/messages/
├── en.json # English (default)
├── es.json # Spanish
└── fr.json # French
Each file is a flat or nested JSON object. BlueCollar uses a two-level namespace structure: a top-level namespace key followed by message keys.
{
"common": {
"home": "Home",
"save": "Save",
"cancel": "Cancel"
},
"workers": {
"title": "Find Skilled Workers",
"noResults": "No workers found"
},
"auth": {
"email": "Email",
"loginTitle": "Login to BlueCollar"
},
"dashboard": {
"title": "Dashboard",
"myWorkers": "My Workers"
}
}| Namespace | Purpose |
|---|---|
common |
Shared UI labels (nav, buttons, status words) |
workers |
Workers listing and profile page strings |
auth |
Authentication forms and labels |
dashboard |
Dashboard page strings |
In Server Components:
import { getTranslations } from 'next-intl/server'
export default async function WorkersPage() {
const t = await getTranslations('workers')
return <h1>{t('title')}</h1>
}In Client Components:
'use client'
import { useTranslations } from 'next-intl'
export default function WorkerCard() {
const t = useTranslations('workers')
return <span>{t('verified')}</span>
}Follow these steps to add a new locale.
Copy en.json as a starting point and translate all values. Keep all keys identical to the English file.
cp packages/app/src/messages/en.json packages/app/src/messages/{locale}.jsonThen translate the values in {locale}.json:
{
"common": {
"home": "Início",
"save": "Salvar",
"cancel": "Cancelar"
},
"workers": {
"title": "Encontrar Trabalhadores Qualificados",
"noResults": "Nenhum trabalhador encontrado"
}
}Add the new locale code to the locales array in packages/app/src/middleware.ts:
const intlMiddleware = createMiddleware({
locales: ['en', 'fr', 'es', 'pt'], // add 'pt'
defaultLocale: 'en',
localePrefix: 'always',
})packages/app/src/components/LanguageSwitcher.tsx renders the locale options. Add the new locale with its display name:
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'es', label: 'Español' },
{ code: 'pt', label: 'Português' },
]The [locale] dynamic segment in packages/app/src/app/[locale]/ handles all localized routes automatically. No additional route changes are needed.
Start the dev server and navigate to http://localhost:3000/{locale} to verify the new locale loads correctly.
The project also accepts translated README files alongside the English README.md. These are standalone Markdown files at the repo root.
| File | Language |
|---|---|
README.md |
English |
README.pt.md |
Portuguese |
- Copy the English README:
cp README.md README.{locale}.md - Translate all user-facing text while preserving code blocks, URLs, and file paths unchanged.
- Add a language switch link at the top below the title:
**[English](./README.md) | [Português](./README.{locale}.md)**
- Add a "Translation" section at the bottom crediting yourself:
## Translation This translation was contributed by the community to make the project accessible to {language} speakers. **Translator:** Your Name / Community
- Update the English
README.mdto link to the new translation:**[English](./README.md) | [Português](./README.{locale}.md)**
When submitting a README translation, add your name (or GitHub handle) in the final ## Translation footer. If multiple people contributed, list them all.
- Fork the repository and create a branch:
git checkout -b i18n/add-{language} - Add or update the translation file in
packages/app/src/messages/ - Ensure every key present in
en.jsonalso exists in your translation file — missing keys fall back to the key name, not the English value - Run the validation script (see below) to check for missing keys
- Open a pull request with the title format:
i18n: add {language} ({locale}) translations
To find missing keys, compare your locale file against en.json:
# Deep comparison — finds missing keys at any nesting level
node -e "
const en = require('./packages/app/src/messages/en.json')
const locale = require('./packages/app/src/messages/{locale}.json')
function findMissing(base, target, path = '') {
return Object.keys(base).flatMap(k => {
const p = path ? path + '.' + k : k
if (!(k in target)) return [p]
if (typeof base[k] === 'object' && base[k] !== null && !Array.isArray(base[k]))
return findMissing(base[k], target[k], p)
return []
})
}
const missing = findMissing(en, locale)
if (missing.length) {
console.log('Missing keys:', missing.join('\n '))
process.exit(1)
} else {
console.log('All keys present!')
}
"You can also add this as a script in packages/app/package.json:
{
"scripts": {
"i18n:check": "node -e \"...\""
}
}- All keys from
en.jsonare present in the translation file - Values are translated (not left as English)
- Variables (
{count},{name}) are preserved — do not translate variable names - ICU plural syntax is correct for the target language
- LanguageSwitcher component includes the new locale
- Middleware
localesarray includes the new locale code - (Optional) README translation is included with language switcher link
- Translation file is valid JSON
- No keys are missing compared to
en.json - Variable placeholders are intact
- Language switcher and middleware are updated
- README translation (if any) preserves URLs, code blocks, and file paths
When new features add strings to en.json, all other locale files must be updated. A missing key will render the raw key string (e.g., workers.newFeature) in the UI.
The CI pipeline will eventually check for missing keys automatically. In the meantime, run the validation script above before merging any PR that adds new UI strings.
next-intl uses the ICU message format for pluralization and interpolation.
Pass dynamic values as the second argument to t():
{
"workers": {
"resultsCount": "Showing {count} workers"
}
}t('resultsCount', { count: 42 })
// → "Showing 42 workers"Use ICU plural syntax to handle singular/plural forms:
{
"workers": {
"reviewCount": "{count, plural, =0 {No reviews} one {# review} other {# reviews}}"
}
}t('reviewCount', { count: 0 }) // → "No reviews"
t('reviewCount', { count: 1 }) // → "1 review"
t('reviewCount', { count: 5 }) // → "5 reviews"Each locale file should provide the plural forms appropriate for that language. For example, Russian has more plural categories than English — consult the CLDR plural rules for the target language.
next-intl integrates with the Intl API for locale-aware formatting:
import { useFormatter } from 'next-intl'
function PriceDisplay({ amount }: { amount: number }) {
const format = useFormatter()
return <span>{format.number(amount, { style: 'currency', currency: 'USD' })}</span>
}// Date formatting
format.dateTime(new Date(), { dateStyle: 'medium' })
// en: "Apr 23, 2026"
// fr: "23 avr. 2026"
// es: "23 abr 2026"Use ICU select for gender-aware or conditional strings:
{
"workers": {
"workerStatus": "{status, select, active {Available} inactive {Unavailable} other {Unknown}}"
}
}- All user-facing UI strings in the message JSON files
- README content (prose, descriptions, instructions)
- Variable names and placeholders (
{count},{name},{email}) - Code blocks, commands, and file paths
- URLs and links
- Brand name "BlueCollar"
- Technical terms that are universally understood (e.g., "API", "JSON", "JWT")
- Use natural, idiomatic phrasing for the target language — avoid literal translations
- Maintain consistent terminology throughout the file
- Respect the tone of the original (professional, helpful, concise)
- For regional variants (e.g., pt-BR vs pt-PT), add a note in the PR description