Type-Safe Translations in Next.js - A Modern Approach
A comprehensive guide to implementing type-safe translations in Next.js with nested keys and template parameters
November 26, 2024
When building multilingual applications in Next.js, we often face several challenges:
- Type safety across translation keys and paths
- Template parameter support
- Prefix support for better organization
- Easy locale switching
- Seamless integration with Next.js routing
Our implementation solves these challenges while providing a delightful developer experience.
Loading...
Let's break down the implementation step by step:
Translation management in Next.js applications can be error-prone. Without proper type safety, developers can easily reference non-existent translation keys, miss required template parameters, or break existing translations during refactoring. Our type system addresses these challenges by:
- Catching missing translations at compile-time rather than runtime
- Providing autocomplete for all available translation paths
- Ensuring template parameters match the expected format
- Making refactoring safer by tracking all translation key dependencies
- Preventing typos in deeply nested translation keys
Loading...
This type system provides:
- Full Type Safety: All translation paths are derived from the actual translation object structure
- Intelligent Path Completion: TypeScript can suggest valid paths based on your translation structure
- Prefix Support: Allows for scoped translation functions that maintain type safety
- Template Type Safety: Ensures template parameters are properly typed
Example of the type inference in action:
Loading...
Let's see how these types resolve with our dictionary structure:
Loading...
Loading...
Here's how this component looks with our translation files:
Translation Files
Loading...
Loading...
-
Type Safety:
- Compile-time validation of translation keys
- Autocomplete support
- Refactoring safety
- Template parameter validation
-
Developer Experience:
- Intuitive API
- Prefix support for better organization
- Comprehensive TypeScript support
- Detailed error messages
-
Flexibility:
- Support for nested keys
- Template parameters
- Multiple translation scopes
- String interpolation
-
Locale Management:
- Easy locale switching
- Persistent locale preference
- Integration with Next.js routing
Loading...
Loading...
Loading...
-
Productivity:
- Faster development with autocomplete
- Fewer runtime errors
- Easy debugging
-
Maintainability:
- Organized translations with prefixes
- Type-safe refactoring
- Clear separation of concerns
The implementation provides a robust foundation for building multilingual applications in Next.js, with strong TypeScript support and a developer-friendly API.
Written with the help of WindSurf's Cascade.
1// Basic usage
2const { t } = useTranslation();
3t('common.welcome');
4
5// With prefix
6const { t } = useTranslation('common');
7t('welcome');
8
9// With template parameters
10t('
1import type translation from 'public/assets/locales/en';
2
3// Helper type that extracts all possible paths from a nested object
4// Returns an array of keys for each level of nesting
5type AllPathsProps<T, Acc extends any[] = []> = T extends string
6 ? Acc
7 : {
8 [K in Extract
1const { t } = useTranslation();
2
3// Valid - path exists in translations
4t('forms.errors.required');
5
6// Error - path doesn't exist in translations
7t('forms.errors.nonexistent');
8
9// With prefix
10const { t: formT } = useTranslation('forms
1// Given this translation structure:
2const translation = {
3 header: {
4 title: "Welcome to My App",
5 subtitle: "Start your journey",
6 nav: {
7 home: "Home",
8 about: "About",
9 contact
greeting
'
, { name
:
'
John
'
});
// "Hello, John!"
13const { setLocale } = useTranslation();
<
keyof
T
,
string
>]
:
Acc
|
AllPathsProps
<
T
[
K
], [
...
Acc
,
K
]>;
9 }[Extract<keyof T, string>];
11// Helper type that converts an object structure into arrays of path segments
12// e.g., { forms: { errors: { required: string } } }
13// → ['forms', 'errors', 'required']
14type PathsToStringProps<T> = T extends string
17 [K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>];
18 }[Extract<keyof T, string>];
20// Utility type that joins string array with a delimiter
21// e.g., Join<['forms', 'errors', 'required'], '.'>
22// → 'forms.errors.required'
23type Join<T extends string[], D extends string> = T extends []
27 : T extends [infer F, ...infer R]
29 ? `${F}${D}${Join<Extract<R, string[]>, D>}`
33// All possible paths in the translation object
34// This creates a union type of all possible dot-notation paths
35export type AllPaths = Join<AllPathsProps<typeof translation>, '.'>;
37// Valid translation paths derived from the translation object structure
38// This ensures type safety by only allowing paths that exist in translations
39export type TranslationPath = Join<PathsToStringProps<typeof translation>, '.'>;
41// Parameters that can be injected into translation templates
42export type TemplateParams = Record<string, string>;
44// The translation function type
45// Paths can be either TranslationPath or a more specific path type
46export type TranslationFunction<Paths extends string = TranslationPath> = (
48 templateParams?: TemplateParams
51// Extracts the remaining path after a prefix
52// Used for creating scoped translation functions
53export type PrefixedPath<
55 Prefix extends string,
56> = Paths extends `${Prefix}.${infer Rest}` ? Rest : never;
58// The return type of useTranslation hook
59export type Results<T> = {
60 locale: string; // Current active locale
61 setLocale: (locale: string) => void; // Function to change locale
62 t: T; // The translation function
'
);
11// Valid - shorter path due to prefix
12formT('errors.required');
:
"
Contact
"
15 required: "This field is required",
16 email: "Please enter a valid email",
18 length: "Password must be at least ${length} characters",
19 uppercase: "Password must contain at least one uppercase letter",
20 number: "Password must contain at least one number"
24 save: "Successfully saved ${itemName}",
25 delete: "Successfully deleted ${itemName}"
31// PathsToStringProps resolves to:
32type PathArrays = PathsToStringProps<typeof translation>;
36 | ['header', 'subtitle']
38 | ['header', 'nav', 'home']
39 | ['header', 'nav', 'about']
40 | ['header', 'nav', 'contact']
42 | ['forms', 'validation']
43 | ['forms', 'validation', 'errors']
44 | ['forms', 'validation', 'errors', 'required']
45 | ['forms', 'validation', 'errors', 'email']
46 | ['forms', 'validation', 'errors', 'password']
47 | ['forms', 'validation', 'errors', 'password', 'length']
48 | ['forms', 'validation', 'errors', 'password', 'uppercase']
49 | ['forms', 'validation', 'errors', 'password', 'number']
50 | ['forms', 'validation', 'success']
51 | ['forms', 'validation', 'success', 'save']
52 | ['forms', 'validation', 'success', 'delete']
1'use client';
2
3// Function overloads to provide correct typing based on usage:
4// 1. No prefix - returns translation function for all paths
5// 2. With prefix - returns translation function for paths under that prefix
6export function useTranslation(): Results<TranslationFunction>;
7export function useTranslation<Partial extends Exclude<AllPaths, TranslationPath>>(
8 prefix: Partial
1{
2 "common": {
3 "welcome": "Welcome to Our App",
4 "description": "Register to get started",
5 "loading": "Loading..."
6 },
7 "languages": {
8 "english": "English"
1{
2 "common": {
3 "welcome": "ברוכים הבאים לאפליקציה שלנו",
4 "description": "הירשמו כדי להתחיל",
5 "loading": "טוען..."
6 },
7 "languages": {
8 "english": "אנגלית"
1'use client';
2
3function WelcomePage() {
4 const { t } = useTranslation();
5 return <h1>{t('common.welcome')}</h1>;
6}
7
1'use client';
2
3function AnalysisPage() {
4 const { t } = useTranslation('analysis');
5 return (
6 <div>
7 <h2>{t('title')}</h2>
8 <p>{t(
1'use client';
2
3function Greeting({ name }: { name: string }) {
4 const { t } = useTranslation();
5 return <p>{t('greeting', { name })}</p>;
6}
7
9): Results<TranslationFunction<PrefixedPath<TranslationPath, Partial>>>;
12 * The useTranslation hook provides a translation function (t) and locale management.
13 * It can be used with or without a prefix for scoped translations.
15 * @param {string} [prefix] - Optional prefix for scoped translations.
16 * @returns {Results<TranslationFunction>} - An object containing the translation function (t),
17 * the current locale, and a function to set the locale.
19export function useTranslation(prefix?: string) {
20 // Get the current route path for locale persistence
21 const { asPath } = useRouter();
23 // Access and track the current locale from our context/state
24 const locale = useLocale();
27 * Function to update the active locale.
28 * This will persist the choice and trigger a re-render.
30 * @param {string} locale - The new locale to set.
32 const setLocale = useCallback(
34 setLocaleCookie(locale);
41 * The main translation function that:
42 * 1. Handles prefixed paths if a prefix was provided
43 * 2. Processes template parameters in the translation string
44 * 3. Falls back gracefully if translation is missing
46 * @param {string} path - The translation path.
47 * @param {TemplateParams} [templateParams] - Optional template parameters.
48 * @returns {string} - The translated string.
50 const t = useCallback(
51 (path: string, templateParams = {}) =>
52 // If prefix provided, automatically prepend it to the path
54 ? getTranslation(`${prefix}.${path}`, locale, templateParams)
55 : getTranslation(path, locale, templateParams),
59 // Return the locale state and translation function
60 // This allows components to both translate and control the locale
69const Component = () => {
70 // Basic usage - access to all translation paths
71 const { t } = useTranslation();
73 // Prefixed usage for form-related translations
74 const { t: formT } = useTranslation('forms');
76 // Prefixed usage specifically for error messages
77 const { t: errorT } = useTranslation('forms.errors');
80 <div className="registration-form">
81 {/* Main header with basic translation */}
82 <h1>{t('common.welcome')}</h1>
83 <p>{t('common.description')}</p>
85 {/* Form section using prefixed translations */}
86 <form onSubmit={handleSubmit}>
87 <div className="form-group">
88 <label className="block text-sm font-medium mb-1">
89 {formT('labels.email')}
93 className="w-full px-3 py-2 border rounded-md"
94 placeholder={formT('placeholders.email')}
95 aria-label={formT('aria.email')}
98 <span className="error-message">
104 {/* Example with template parameters */}
105 <p className="password-requirement">
106 {formT('validation.password.length', { length: '8' })}
109 {/* Button with loading state */}
110 <button type="submit">
111 {isLoading ? t('common.loading') : formT('buttons.submit')}
115 {/* Language switcher */}
116 <div className="language-switcher">
117 <button onClick={() => setLocale('en')}>
118 {t('languages.english')}
120 <button onClick={() => setLocale('he')}>
121 {t('languages.hebrew')}
,
13 "email": "Email Address"
16 "email": "Enter your email"
19 "email": "Email input field"
26 "length": "Password must be at least ${length} characters"
30 "required": "This field is required"
,
13 "email": "כתובת אימייל"
16 "email": "הכניסו את האימייל שלכם"
19 "email": "שדה קלט אימייל"
26 "length": "הסיסמה חייבת להכיל לפחות ${length} תווים"
30 "required": "שדה זה הוא חובה"
'
description
'
)}
</
p
>
Type-Safe Translations in Next.js - A Modern Approach - Itamar Sharify