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

Why Another Translation Hook?

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.

The Gist

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('greeting', { name: 'John' }); // "Hello, John!"
11
12// Switching locale
13const { setLocale } = useTranslation();
14setLocale('fr');
15

Implementation Details

Let's break down the implementation step by step:

1. Types

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
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<keyof T, string>]: Acc | AllPathsProps<T[K], [...Acc, K]>;
9 }[Extract<keyof T, string>];
10
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
15 ? []
16 : {
17 [K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>];
18 }[Extract<keyof T, string>];
19
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 []
24 ? never
25 : T extends [infer F]
26 ? F
27 : T extends [infer F, ...infer R]
28 ? F extends string
29 ? `${F}${D}${Join<Extract<R, string[]>, D>}`
30 : never
31 : string;
32
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>, '.'>;
36
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>, '.'>;
40
41// Parameters that can be injected into translation templates
42export type TemplateParams = Record<string, string>;
43
44// The translation function type
45// Paths can be either TranslationPath or a more specific path type
46export type TranslationFunction<Paths extends string = TranslationPath> = (
47 path?: Paths | '',
48 templateParams?: TemplateParams
49) => string;
50
51// Extracts the remaining path after a prefix
52// Used for creating scoped translation functions
53export type PrefixedPath<
54 Paths extends string,
55 Prefix extends string,
56> = Paths extends `${Prefix}.${infer Rest}` ? Rest : never;
57
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
63};
64

This type system provides:

  1. Full Type Safety: All translation paths are derived from the actual translation object structure
  2. Intelligent Path Completion: TypeScript can suggest valid paths based on your translation structure
  3. Prefix Support: Allows for scoped translation functions that maintain type safety
  4. Template Type Safety: Ensures template parameters are properly typed

Example of the type inference in action:

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');
11// Valid - shorter path due to prefix
12formT('errors.required');
13

Let's see how these types resolve with our dictionary structure:

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: "Contact"
10 }
11 },
12 forms: {
13 validation: {
14 errors: {
15 required: "This field is required",
16 email: "Please enter a valid email",
17 password: {
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"
21 }
22 },
23 success: {
24 save: "Successfully saved ${itemName}",
25 delete: "Successfully deleted ${itemName}"
26 }
27 }
28 }
29} as const;
30
31// PathsToStringProps resolves to:
32type PathArrays = PathsToStringProps<typeof translation>;
33/*
34 | ['header']
35 | ['header', 'title']
36 | ['header', 'subtitle']
37 | ['header', 'nav']
38 | ['header', 'nav', 'home']
39 | ['header', 'nav', 'about']
40 | ['header', 'nav', 'contact']
41 | ['forms']
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']
53*/
54

2. Translation Hook

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
9): Results<TranslationFunction<PrefixedPath<TranslationPath, Partial>>>;
10
11/**
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.
14 *
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.
18 */
19export function useTranslation(prefix?: string) {
20 // Get the current route path for locale persistence
21 const { asPath } = useRouter();
22
23 // Access and track the current locale from our context/state
24 const locale = useLocale();
25
26 /**
27 * Function to update the active locale.
28 * This will persist the choice and trigger a re-render.
29 *
30 * @param {string} locale - The new locale to set.
31 */
32 const setLocale = useCallback(
33 (locale: string) => {
34 setLocaleCookie(locale);
35 router.push(asPath);
36 },
37 [asPath]
38 );
39
40 /**
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
45 *
46 * @param {string} path - The translation path.
47 * @param {TemplateParams} [templateParams] - Optional template parameters.
48 * @returns {string} - The translated string.
49 */
50 const t = useCallback(
51 (path: string, templateParams = {}) =>
52 // If prefix provided, automatically prepend it to the path
53 prefix
54 ? getTranslation(`${prefix}.${path}`, locale, templateParams)
55 : getTranslation(path, locale, templateParams),
56 [prefix, locale]
57 );
58
59 // Return the locale state and translation function
60 // This allows components to both translate and control the locale
61 return {
62 locale,
63 setLocale,
64 t,
65 };
66}
67
68// Example usage:
69const Component = () => {
70 // Basic usage - access to all translation paths
71 const { t } = useTranslation();
72
73 // Prefixed usage for form-related translations
74 const { t: formT } = useTranslation('forms');
75
76 // Prefixed usage specifically for error messages
77 const { t: errorT } = useTranslation('forms.errors');
78
79 return (
80 <div className="registration-form">
81 {/* Main header with basic translation */}
82 <h1>{t('common.welcome')}</h1>
83 <p>{t('common.description')}</p>
84
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')}
90 </label>
91 <input
92 type="email"
93 className="w-full px-3 py-2 border rounded-md"
94 placeholder={formT('placeholders.email')}
95 aria-label={formT('aria.email')}
96 ></input>
97 {hasError && (
98 <span className="error-message">
99 {errorT('required')}
100 </span>
101 )}
102 </div>
103
104 {/* Example with template parameters */}
105 <p className="password-requirement">
106 {formT('validation.password.length', { length: '8' })}
107 </p>
108
109 {/* Button with loading state */}
110 <button type="submit">
111 {isLoading ? t('common.loading') : formT('buttons.submit')}
112 </button>
113 </form>
114
115 {/* Language switcher */}
116 <div className="language-switcher">
117 <button onClick={() => setLocale('en')}>
118 {t('languages.english')}
119 </button>
120 <button onClick={() => setLocale('he')}>
121 {t('languages.hebrew')}
122 </button>
123 </div>
124 </div>
125 );
126};
127

Here's how this component looks with our translation files:

Translation Files
1{
2 "common": {
3 "welcome": "Welcome to Our App",
4 "description": "Register to get started",
5 "loading": "Loading..."
6 },
7 "languages": {
8 "english": "English",
9 "hebrew": "Hebrew"
10 },
11 "forms": {
12 "labels": {
13 "email": "Email Address"
14 },
15 "placeholders": {
16 "email": "Enter your email"
17 },
18 "aria": {
19 "email": "Email input field"
20 },
21 "buttons": {
22 "submit": "Register"
23 },
24 "validation": {
25 "password": {
26 "length": "Password must be at least ${length} characters"
27 }
28 },
29 "errors": {
30 "required": "This field is required"
31 }
32 }
33}
34
1{
2 "common": {
3 "welcome": "ברוכים הבאים לאפליקציה שלנו",
4 "description": "הירשמו כדי להתחיל",
5 "loading": "טוען..."
6 },
7 "languages": {
8 "english": "אנגלית",
9 "hebrew": "עברית"
10 },
11 "forms": {
12 "labels": {
13 "email": "כתובת אימייל"
14 },
15 "placeholders": {
16 "email": "הכניסו את האימייל שלכם"
17 },
18 "aria": {
19 "email": "שדה קלט אימייל"
20 },
21 "buttons": {
22 "submit": "הרשמה"
23 },
24 "validation": {
25 "password": {
26 "length": "הסיסמה חייבת להכיל לפחות ${length} תווים"
27 }
28 },
29 "errors": {
30 "required": "שדה זה הוא חובה"
31 }
32 }
33}
34

Welcome to Our App

Register to get started

Password must be at least 8 characters

Features

  1. Type Safety:

    • Compile-time validation of translation keys
    • Autocomplete support
    • Refactoring safety
    • Template parameter validation
  2. Developer Experience:

    • Intuitive API
    • Prefix support for better organization
    • Comprehensive TypeScript support
    • Detailed error messages
  3. Flexibility:

    • Support for nested keys
    • Template parameters
    • Multiple translation scopes
    • String interpolation
  4. Locale Management:

    • Easy locale switching
    • Persistent locale preference
    • Integration with Next.js routing

Usage Examples

Basic Usage

1'use client';
2
3function WelcomePage() {
4 const { t } = useTranslation();
5 return <h1>{t('common.welcome')}</h1>;
6}
7

With Prefix

1'use client';
2
3function AnalysisPage() {
4 const { t } = useTranslation('analysis');
5 return (
6 <div>
7 <h2>{t('title')}</h2>
8 <p>{t('description')}</p>
9 </div>
10 );
11}
12

With Template Parameters

1'use client';
2
3function Greeting({ name }: { name: string }) {
4 const { t } = useTranslation();
5 return <p>{t('greeting', { name })}</p>;
6}
7

Benefits

  1. Productivity:

    • Faster development with autocomplete
    • Fewer runtime errors
    • Easy debugging
  2. 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.