Add react-i18next to a React NPM Library Package
This skill adds internationalization (i18n) to a React NPM library package using react-i18next with an isolated i18n instance pattern that avoids conflicts with the consuming application's own i18n setup.
Arguments
- •
default-language(optional): The default language code (e.g.,en,pt). Defaults toen. - •
additional-languages(optional): Space-separated additional language codes to create translation files for (e.g.,pt es fr).
Overview
The implementation follows these principles:
- •Isolated i18n instance — Uses
i18next.createInstance()instead of the global instance to prevent conflicts with the consuming app - •Dedicated namespace — All library translations live under a unique namespace (derived from the package name) to avoid key collisions
- •Consumer customization — The library's Provider accepts
languageandtranslationsprops so consumers can override/extend translations - •Synchronous init — Uses
initImmediate: falseso translations are available immediately without async loading - •Dynamic Zod schemas — Zod validation schemas are wrapped in factory functions +
useMemoto re-evaluate when language changes - •Utility function i18n — Utility functions accept an optional
tparameter with English hardcoded fallback
Step-by-Step Instructions
Step 1: Analyze the Project
Before making any changes, thoroughly analyze the project structure:
- •
Find the entry point — Read
package.jsonto find themain/modulefield, then read the entry file (usuallysrc/index.ts) to understand all public exports. - •
Find the Provider/Context — Search for React Context providers. Look for patterns like:
codeGlob: src/**/Context*.tsx, src/**/Provider*.tsx, src/contexts/** Grep: createContext, Provider
- •
Find all components with hardcoded strings — Search for visible text:
codeGrep patterns: placeholder=", label, >.*</, title=", aria-label="
Read each component and catalog all hardcoded strings (labels, placeholders, error messages, validation messages, button text, titles, etc.).
- •
Find Zod schemas — Search for
z.object,z.string(),.email(,.min(,.max(to identify validation schemas with hardcoded error messages. - •
Find utility functions with strings — Check
src/utils/orsrc/helpers/for functions that return user-facing strings (e.g., password strength validators, formatters with error messages). - •
Count total strings — Estimate the total number of unique translatable strings. This helps plan the translation file structure.
Step 2: Install Dependencies
npm install i18next react-i18next
Install as regular dependencies (not peer), since the library uses its own isolated instance that doesn't need to share with the consuming app.
Step 3: Create Translation Files
Directory structure:
src/
i18n/
index.ts # i18n setup + hooks
locales/
en.ts # English translations (always required)
pt.ts # Additional languages as needed
es.ts
Translation file pattern (src/i18n/locales/en.ts):
const en = {
common: {
email: 'Email',
password: 'Password',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
loading: 'Loading...',
// ... shared strings used across multiple components
},
validation: {
emailInvalid: 'Please enter a valid email address',
passwordRequired: 'Password is required',
passwordMinLength: 'Password must be at least {{minLength}} characters',
// ... all validation error messages
},
// One section per component/feature:
login: {
signIn: 'Sign In',
signingIn: 'Signing in...',
rememberMe: 'Remember me',
// ...
},
register: { /* ... */ },
// etc.
};
export default en;
Key guidelines for translation files:
- •Use flat namespace with prefixes per feature/component (e.g.,
login.signIn,validation.emailInvalid) - •Use
{{variable}}syntax for interpolation (i18next standard) - •Keep keys in camelCase
- •Group by feature, not by component file
- •Put shared strings in
common.* - •Put all validation messages in
validation.* - •Export as
defaultfor clean imports
Additional language files:
Copy the English file structure exactly and translate all values. The keys must be identical.
Step 4: Create i18n Setup (src/i18n/index.ts)
import i18next, { type Resource } from 'i18next';
import { initReactI18next, useTranslation } from 'react-i18next';
import en from './locales/en';
// import additional languages...
// Derive namespace from package name to avoid collisions
export const NAMESPACE = 'your-lib-name';
export const defaultTranslations = { en /* , pt, es, ... */ };
export function createI18nInstance(
language: string = 'en',
customTranslations?: Record<string, Record<string, unknown>>
) {
const instance = i18next.createInstance();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resources: Record<string, Record<string, any>> = {
en: { [NAMESPACE]: { ...en } },
// Add built-in languages here...
};
// Merge custom translations from consumer
if (customTranslations) {
for (const [lang, translations] of Object.entries(customTranslations)) {
if (resources[lang]) {
resources[lang][NAMESPACE] = {
...resources[lang][NAMESPACE],
...translations,
};
} else {
resources[lang] = { [NAMESPACE]: { ...translations } };
}
}
}
instance.use(initReactI18next).init({
resources: resources as Resource,
lng: language,
fallbackLng: 'en',
defaultNS: NAMESPACE,
ns: [NAMESPACE],
interpolation: { escapeValue: false },
initImmediate: false, // Synchronous init — critical for SSR and tests
});
return instance;
}
export function useLibTranslation() {
return useTranslation(NAMESPACE);
}
Critical details:
- •
i18next.createInstance()— NOT the globali18nextinstance - •
initImmediate: false— Ensures synchronous initialization - •
escapeValue: false— React already escapes output - •The
customTranslationsparameter allows consumers to add/override translations - •Export the
useLibTranslationhook for use in components
Step 5: Modify the Provider/Context Config Types
Add i18n configuration to the library's config interface:
export interface LibConfig {
// ... existing config props
language?: string;
translations?: Record<string, Record<string, unknown>>;
}
Step 6: Integrate i18n in the Provider
In the Provider component:
import { I18nextProvider } from 'react-i18next';
import { createI18nInstance } from '../i18n';
export const LibProvider: React.FC<ProviderProps> = ({ config, children }) => {
// Create i18n instance (memoized)
const i18nInstance = useMemo(
() => createI18nInstance(config.language, config.translations),
[config.language, config.translations]
);
// Handle language changes
const currentLang = useRef(config.language);
useEffect(() => {
if (config.language && config.language !== currentLang.current) {
i18nInstance.changeLanguage(config.language);
currentLang.current = config.language;
}
}, [config.language, i18nInstance]);
return (
<I18nextProvider i18n={i18nInstance}>
<LibContext.Provider value={contextValue}>
{children}
</LibContext.Provider>
</I18nextProvider>
);
};
Step 7: Modify Components
For each component with hardcoded strings, follow this pattern:
Import the translation hook:
import { useLibTranslation } from '../i18n';
Inside the component:
const { t } = useLibTranslation();
Replace all hardcoded strings:
// Before:
<Label>Email</Label>
<Input placeholder="Enter your email" />
<Button>Sign In</Button>
// After:
<Label>{t('common.email')}</Label>
<Input placeholder={t('login.emailPlaceholder')} />
<Button>{t('login.signIn')}</Button>
For Zod schemas with validation messages:
Move schemas into factory functions and wrap with useMemo:
// Before (outside component):
const schema = z.object({
email: z.string().email('Please enter a valid email'),
});
// After:
function createSchema(t: (key: string) => string) {
return z.object({
email: z.string().email(t('validation.emailInvalid')),
});
}
// Inside component:
const { t } = useLibTranslation();
const schema = useMemo(() => createSchema(t), [t]);
This ensures validation messages update when the language changes.
For interpolated strings:
// Translation key: "Showing {{from}} to {{to}} of {{total}}"
t('common.showingFromTo', { from: startIndex, to: endIndex, total: totalItems })
For conditional text:
// Translation keys: "status.active", "status.inactive", etc.
const STATUS_KEYS: Record<string, string> = {
active: 'status.active',
inactive: 'status.inactive',
};
// Usage:
t(STATUS_KEYS[status])
Step 8: Modify Utility Functions
For utility functions that return user-facing strings, add an optional t parameter:
export function validateSomething(
value: string,
options: {
// ... existing options
t?: (key: string, opts?: Record<string, unknown>) => string;
} = {}
) {
const { t } = options;
// Helper: use translation if available, otherwise hardcoded English fallback
const msg = (key: string, fallback: string, interpolation?: Record<string, unknown>) =>
t ? t(key, interpolation) : fallback;
// Usage:
feedback.push(msg('validation.minLength', `Must be at least ${min} characters`, { min }));
}
Components that call these utilities should pass { t }:
const { t } = useLibTranslation();
const result = validateSomething(value, { t });
Step 9: Update Public Exports (src/index.ts)
Add i18n exports to the entry point:
// i18n
export { createI18nInstance, useLibTranslation, NAMESPACE, defaultTranslations } from './i18n';
export { default as enTranslations } from './i18n/locales/en';
export { default as ptTranslations } from './i18n/locales/pt';
// ... other language exports
This allows consumers to:
- •Access default translations for extending/overriding
- •Use the translation hook in their own components
- •Reference the namespace constant
Step 10: Update Tests
Tests need the NAuthProvider (or equivalent) wrapper to initialize i18n. If tests already use the Provider wrapper, they should work without changes.
For form validation tests, use fireEvent.input + fireEvent.submit instead of fireEvent.change + fireEvent.click for more reliable Zod schema triggering in jsdom.
If tests fail because translations aren't loading, ensure:
- •The Provider wrapper in tests includes the i18n setup
- •
initImmediate: falseis set in the i18n init config
Step 11: Verify
Run these checks in order:
npm run type-check # TypeScript must pass npm run lint # No new warnings npm test # All tests must pass npm run build # Build must succeed (ES + CJS)
Consumer Usage Examples
Zero config (English default):
<LibProvider config={{ apiUrl: 'https://api.example.com' }}>
<App />
</LibProvider>
With language selection:
<LibProvider config={{ apiUrl: 'https://api.example.com', language: 'pt' }}>
<App />
</LibProvider>
With custom translations:
<LibProvider config={{
apiUrl: 'https://api.example.com',
language: 'es',
translations: {
es: {
common: { email: 'Correo electrónico' },
login: { signIn: 'Iniciar sesión' },
}
}
}}>
<App />
</LibProvider>
Overriding default translations:
<LibProvider config={{
apiUrl: 'https://api.example.com',
translations: {
en: { login: { signIn: 'Log In' } } // overrides "Sign In"
}
}}>
<App />
</LibProvider>
Checklist
Before marking complete, verify:
- •
i18nextandreact-i18nextinstalled as regular dependencies - • Isolated i18n instance created with
createInstance()(not global) - •
initImmediate: falseset in init config - • Translation files created for all specified languages
- • Translation keys organized by feature with
common.*andvalidation.*shared sections - • Provider wraps children with
<I18nextProvider> - • Config interface includes
language?andtranslations?props - • All hardcoded strings in components replaced with
t()calls - • Zod schemas use factory functions +
useMemowithtdependency - • Utility functions accept optional
tparameter with English fallback - • i18n utilities exported from entry point
- • TypeScript type-check passes
- • Lint passes (no new warnings)
- • Tests pass
- • Build succeeds