Internationalization (i18n) & Localization (l10n) β
Grant provides comprehensive internationalization (i18n) and localization (l10n) support across both the API and web application, enabling you to serve users in multiple languages.
Overview β
Current Language Support:
- π¬π§ English (en) - Default
- π©πͺ German (de)
Architecture:
- Shared package:
@grantjs/i18nis the single source of translation data (errors, common, email) and locale constants (SUPPORTED_LOCALES,DEFAULT_LOCALE,SupportedLocale,isSupportedLocale). Both API and web consume from this package. - API:
i18nextwith a single namespace and merged messages from@grantjs/i18n; HTTP middleware for server-side translations; keys use dot only (e.g.errors.auth.tokenExpired). - Web:
next-intlloads shared messages from@grantjs/i18nand merges with web-only locale files; uses the same dot keys; resolves APItranslationKeywitht(translationKey). - Auto-sync: Web app sends user's locale to API via
Accept-Language; API and GraphQL/REST responses includetranslationKeyso clients can resolve messages from the shared source.
π How It Works β
Request Flow β
User sets language in web app
β
Web app detects locale from URL (/en/... or /de/...)
β
Apollo Client adds Accept-Language header to all requests
β
API receives request with Accept-Language: de
β
i18n middleware detects and sets locale
β
Business logic throws errors with translation keys
β
Error middleware translates using detected locale
β
Response sent with localized messagesExample Flow β
User (German browser) β /de/organizations
β Apollo adds: Accept-Language: de
β API endpoint called
β Handler throws: NotFoundError('Organization not found')
β Mapper sets translationKey: 'errors.notFound.organization'
β Middleware translates and responds with error + translationKey
β Client receives German message and can resolve by translationKey from @grantjs/i18nπ§ API Internationalization β
Shared package: @grantjs/i18n β
Translation data and locale constants live in packages/@grantjs/i18n/:
- Locale constants:
SUPPORTED_LOCALES,DEFAULT_LOCALE,SupportedLocale,isSupportedLocale()β import from@grantjs/i18n. - Translation data:
locales/{lng}/errors.json,common.json,email.json; merged per locale aslocales/en.json,locales/de.jsonfor consumers that need a single object. - Key convention: Use dot only (e.g.
errors.auth.tokenExpired,common.success.emailVerified,email.verification.subject). No colon; API and web use the same key format. - Exports:
getLocalesPath(),getMergedMessages(locale)for API; web can import@grantjs/i18n/locales/en.json(andde.json) for client-side loading.
translationKey contract β
API responses use a translation key so clients can show messages in the user's locale from the shared source:
- GraphQL: Error
extensionsincludetranslationKey(and optionallytranslationParams). Example:errors.auth.tokenExpired,errors.notFound.organization. - REST: Error JSON body includes
error(localized message),code,translationKey, and optionallytranslationParamsandextensions. - Client: Use the key as-is with your t function:
t(translationKey). Keys are dot-separated (e.g.errors.auth.tokenExpired). No conversion needed.
API i18n layout β
apps/api/src/i18n/
βββ config.ts # i18next init with merged messages from @grantjs/i18n
βββ helpers.ts # translateError, translateStatic, getLocale
βββ index.ts # Public exportsConfiguration β
The API uses i18next with environment-based configuration:
// apps/api/src/config/env.config.ts
export const I18N_CONFIG = {
supportedLocales: ['en', 'de'],
defaultLocale: 'en',
debug: false, // true in development
};Environment Variables:
I18N_DEFAULT_LOCALE=en # Default locale
I18N_DEBUG=false # Enable debug loggingTranslation Files β
English (en/errors.json) β
{
"auth": {
"invalidCredentials": "Invalid email or password",
"userNotFound": "User not found",
"unauthorized": "You are not authorized to perform this action"
},
"notFound": {
"resource": "{{resource}} not found",
"organization": "Organization not found",
"user": "User not found"
},
"validation": {
"required": "{{field}} is required",
"invalid": "{{field}} is invalid"
}
}German (de/errors.json) β
{
"auth": {
"invalidCredentials": "UngΓΌltige E-Mail oder Passwort",
"userNotFound": "Benutzer nicht gefunden",
"unauthorized": "Sie sind nicht berechtigt, diese Aktion auszufΓΌhren"
},
"notFound": {
"resource": "{{resource}} nicht gefunden",
"organization": "Organisation nicht gefunden",
"user": "Benutzer nicht gefunden"
},
"validation": {
"required": "{{field}} ist erforderlich",
"invalid": "{{field}} ist ungΓΌltig"
}
}Error Standardization with i18n β
All API errors use standardized error classes with translation support:
import { NotFoundError, AuthenticationError, ValidationError } from '@/lib/errors';
// Simple error
throw new NotFoundError(
'User not found', // Fallback message
'errors:notFound.user' // Translation key
);
// With interpolation
throw new NotFoundError(
`Invitation with id ${id} not found`,
'errors:notFound.invitation',
{ id } // Dynamic params
);
// Validation error
throw new ValidationError('Email is required', [], 'errors:validation.required', {
field: 'email',
});Error Classes β
| Class | HTTP Status | Use Case | Example |
|---|---|---|---|
AuthenticationError | 401 | Auth failures | Invalid credentials |
AuthorizationError | 403 | Permission denied | Insufficient permissions |
NotFoundError | 404 | Resource not found | User not found |
ValidationError | 400 | Invalid input | Required field missing |
ConflictError | 409 | Resource conflict | Email already exists |
BadRequestError | 400 | Malformed request | Invalid request format |
Translation Helpers β
import { translateError, t, getLocale } from '@/i18n';
// Translate an error
const localizedMessage = translateError(req, error);
// Translate a key
const message = t(req, 'errors:auth.unauthorized');
// Get current locale
const locale = getLocale(req); // 'en' or 'de'
// Translate without request (background jobs)
import { translateStatic } from '@/i18n';
const message = translateStatic('errors:auth.unauthorized', 'de');Middleware Integration β
The API automatically translates errors before sending responses:
// apps/api/src/middleware/auth.middleware.ts
export function errorHandler(error: Error, req: Request, res: Response) {
if (error instanceof ApiError) {
const localizedMessage = translateError(req, error);
return res.status(error.statusCode).json({
error: localizedMessage, // Localized!
code: error.code,
...(error.extensions && { extensions: error.extensions }),
});
}
// ... fallback handling
}π₯οΈ Web App Internationalization β
Architecture β
apps/web/i18n/
βββ locales/
β βββ en.json # English translations
β βββ de.json # German translations
βββ config.ts # next-intl configuration
βββ request.ts # Server-side i18n
βββ routing.ts # Locale routing config
βββ navigation.ts # Localized navigationConfiguration β
The web app uses next-intl for Next.js App Router:
// apps/web/i18n/routing.ts
export const locales = ['en', 'de'] as const;
export const defaultLocale = 'en' as const;
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'always', // Always show locale in URL
});URL Structure β
Locale is always visible in the URL:
https://app.example.com/en/organizations β English
https://app.example.com/de/organizations β GermanTranslation Files β
// apps/web/i18n/locales/en.json
{
"common": {
"welcome": "Welcome",
"save": "Save",
"cancel": "Cancel"
},
"organizations": {
"title": "Organizations",
"create": "Create Organization",
"name": "Organization Name"
},
"errors": {
"somethingWentWrong": "Something went wrong",
"tryAgain": "Please try again"
}
}// apps/web/i18n/locales/de.json
{
"common": {
"welcome": "Willkommen",
"save": "Speichern",
"cancel": "Abbrechen"
},
"organizations": {
"title": "Organisationen",
"create": "Organisation erstellen",
"name": "Organisationsname"
},
"errors": {
"somethingWentWrong": "Etwas ist schief gelaufen",
"tryAgain": "Bitte versuchen Sie es erneut"
}
}Usage in Components β
Server Components β
import { getTranslations } from 'next-intl/server';
export default async function OrganizationsPage() {
const t = await getTranslations('organizations');
return (
<div>
<h1>{t('title')}</h1>
<button>{t('create')}</button>
</div>
);
}Client Components β
'use client';
import { useTranslations } from 'next-intl';
export function OrganizationForm() {
const t = useTranslations('organizations');
return (
<form>
<label>{t('name')}</label>
<button type="submit">{t('save')}</button>
</form>
);
}Localized Navigation β
import { Link } from '@/i18n/routing';
// Automatically includes locale prefix
<Link href="/organizations">Organizations</Link>
// Renders: /en/organizations or /de/organizationsLanguage Switcher β
'use client';
import { usePathname, useRouter } from '@/i18n/routing';
import { useLocale } from 'next-intl';
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const currentLocale = useLocale();
const switchLocale = (locale: 'en' | 'de') => {
router.replace(pathname, { locale });
};
return (
<select value={currentLocale} onChange={(e) => switchLocale(e.target.value)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
);
}π API-Web Integration β
Auto-Sync with Apollo Client β
The web app automatically sends the user's locale to the API:
// apps/web/lib/apollo-client.ts
import { setContext } from '@apollo/client/link/context';
// Detect locale from URL
function getCurrentLocale(): string {
if (typeof window !== 'undefined') {
const pathSegments = window.location.pathname.split('/');
const locale = pathSegments[1];
return ['en', 'de'].includes(locale) ? locale : 'en';
}
return 'en';
}
// Add locale to every API request
const authLink = setContext((_, { headers }) => {
const locale = getCurrentLocale();
const accessToken = useAuthStore.getState().accessToken;
return {
headers: {
...headers,
'accept-language': locale, // β API uses this!
...(accessToken && { authorization: `Bearer ${accessToken}` }),
},
};
});
const client = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache(),
});Error Handling β
Errors from the API are automatically localized:
'use client';
import { useMutation } from '@apollo/client';
import { useTranslations } from 'next-intl';
export function CreateOrganization() {
const t = useTranslations('errors');
const [createOrg, { error }] = useMutation(CREATE_ORGANIZATION);
// API error is already translated!
if (error) {
return <div className="error">{error.message}</div>;
}
return (
<button onClick={() => createOrg()}>
Create
</button>
);
}π Adding a New Language β
Step 1: Update API β
Add locale to config:
typescript// apps/api/src/config/env.config.ts export const I18N_CONFIG = { supportedLocales: ['en', 'de', 'fr'], // β Add 'fr' // ... };Create translation files:
bashmkdir -p apps/api/src/i18n/locales/fr touch apps/api/src/i18n/locales/fr/errors.json touch apps/api/src/i18n/locales/fr/common.jsonTranslate messages:
json// apps/api/src/i18n/locales/fr/errors.json { "auth": { "invalidCredentials": "Email ou mot de passe invalide", "userNotFound": "Utilisateur non trouvΓ©" } }
Step 2: Update Web App β
Add locale to routing:
typescript// apps/web/i18n/routing.ts export const locales = ['en', 'de', 'fr'] as const; // β Add 'fr'Create translation file:
bashtouch apps/web/i18n/locales/fr.jsonTranslate UI strings:
json// apps/web/i18n/locales/fr.json { "common": { "welcome": "Bienvenue", "save": "Enregistrer" } }Update Apollo Client validation:
typescript// apps/web/lib/apollo-client.ts return ['en', 'de', 'fr'].includes(locale) ? locale : 'en'; // β Add 'fr'Update language switcher:
tsx<select> <option value="en">English</option> <option value="de">Deutsch</option> <option value="fr">FranΓ§ais</option> {/* β Add French */} </select>
π Best Practices β
1. Always Provide Fallback Messages β
// β
Good - has fallback
throw new NotFoundError(
'User not found', // β Fallback in English
'errors:notFound.user'
);
// β Bad - no fallback
throw new NotFoundError('', 'errors:notFound.user');2. Use Specific Translation Keys β
// β
Good - specific key
throw new AuthenticationError(
'Invalid credentials',
'errors:auth.invalidCredentials' // β Specific!
);
// β Bad - generic key
throw new AuthenticationError(
'Invalid credentials',
'errors:invalid' // β Too generic
);3. Group Keys by Domain β
{
"auth": { ... }, // Authentication errors
"validation": { ... }, // Validation errors
"notFound": { ... }, // Not found errors
"common": { ... } // Common messages
}4. Use Interpolation for Dynamic Values β
// β
Good - uses interpolation
throw new NotFoundError(
`Invitation with id ${id} not found`,
'errors:notFound.invitation',
{ id } // β Interpolation params
);
// β Bad - hardcoded value
throw new NotFoundError(
`Invitation with id ${id} not found`,
'errors:notFound.invitation' // β No params
);5. Keep Messages User-Friendly β
{
// β
Good - user-friendly
"invalidCredentials": "Invalid email or password",
// β Bad - too technical
"invalidCredentials": "Authentication failed: ERR_INVALID_CREDENTIALS"
}6. Be Consistent Across Languages β
Ensure all locales have the same keys:
# Check for missing keys
diff <(jq -r 'keys[]' apps/api/src/i18n/locales/en/errors.json | sort) \
<(jq -r 'keys[]' apps/api/src/i18n/locales/de/errors.json | sort)π§ͺ Testing Localization β
API Testing β
# Test English error
curl -H "Accept-Language: en" \
http://localhost:4000/api/users/invalid-id
# Response: {"error": "User not found", "code": "NOT_FOUND"}
# Test German error
curl -H "Accept-Language: de" \
http://localhost:4000/api/users/invalid-id
# Response: {"error": "Benutzer nicht gefunden", "code": "NOT_FOUND"}
# Test with authentication
curl -X POST \
-H "Accept-Language: de" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"wrong"}' \
http://localhost:4000/api/auth/login
# Response: {"error": "UngΓΌltige E-Mail oder Passwort", ...}Web App Testing β
// Unit test with next-intl
import { NextIntlClientProvider } from 'next-intl';
import { render } from '@testing-library/react';
const messages = {
organizations: {
title: 'Organizations',
},
};
test('renders translated content', () => {
const { getByText } = render(
<NextIntlClientProvider locale="en" messages={messages}>
<OrganizationsPage />
</NextIntlClientProvider>
);
expect(getByText('Organizations')).toBeInTheDocument();
});Integration Testing β
// Test full flow with locale
describe('Localized Error Flow', () => {
it('returns German errors when locale is de', async () => {
const response = await request(app).get('/api/users/invalid-id').set('Accept-Language', 'de');
expect(response.body.error).toBe('Benutzer nicht gefunden');
});
});π Translation Coverage β
Current Statistics (October 2025) β
API:
- English: 49 lines (47 error messages + 2 common)
- German: 49 lines (100% coverage)
- Translation keys: 33 unique keys
- Error classes: 6 standardized classes
Web:
- English: ~200 strings
- German: ~200 strings (100% coverage)
Verify Coverage β
# API - Check for missing translations
cd apps/api
diff <(grep -o '"[^"]*":' src/i18n/locales/en/errors.json | sort) \
<(grep -o '"[^"]*":' src/i18n/locales/de/errors.json | sort)
# Web - Check for missing translations
cd apps/web
diff <(jq -r 'paths | join(".")' i18n/locales/en.json | sort) \
<(jq -r 'paths | join(".")' i18n/locales/de.json | sort)π Troubleshooting β
API Returns English Despite Accept-Language Header β
Cause: i18n middleware not initialized or placed incorrectly
Solution:
- Check server startup logs for i18n initialization
- Verify middleware order in
server.ts:typescriptapp.use(i18nMiddleware); // β Must be before routes app.use('/api', apiRoutes);
Translation Key Not Found β
Cause: Key doesn't exist in translation file
Solution:
- Check key exists in
src/i18n/locales/en/errors.json - Verify key format:
errors:category.keynotcategory.key - Check for typos in key name
Web App Shows Wrong Language β
Cause: Locale not detected from URL or Apollo Client not sending header
Solution:
- Verify URL has locale prefix:
/en/...not/... - Check
getCurrentLocale()function inapollo-client.ts - Inspect network requests in DevTools for
Accept-Languageheader
Missing Translation in New Locale β
Cause: Translation file incomplete
Solution:
- Copy structure from
enlocale - Translate all values
- Verify with diff command (see "Verify Coverage" above)
π― Summary β
Grant's i18n/l10n implementation provides:
β
Seamless Integration - API and web app work together automatically
β
Type Safety - TypeScript ensures correct usage
β
Standardized Errors - Proper HTTP status codes + i18n
β
Easy to Extend - Add new languages in minutes
β
Developer Friendly - Clear APIs and helper functions
β
Production Ready - Used in production with 100% coverage
Key Features:
- Automatic locale detection from URL
- Apollo Client auto-sends
Accept-Languageheader - All API errors properly typed with translation keys
- Fallback to English if translation missing
- Interpolation support for dynamic values
- Minimal performance overhead (<1ms per request)
π Related Documentation β
- Error Standardization Guide - Complete error class reference
- Email Service - Email localization examples
- Development Guide - Adding new features with i18n
- API Reference - REST API error responses
π€ Contributing β
Want to add a new language or improve translations?
- Check the Development Guide
- Follow the "Adding a New Language" section above
- Submit a PR with both API and Web translations
- Include tests for new translations
Translation Contributors Welcome! Native speakers help us provide better translations.
Questions? File an issue or check the Development Guide.