Type-safe i18n in react and prisma: validating translation keys at build time with typescript#
The /src/locales/en.json directory pattern widely adopted across open-source codebases represents the standard architecture for software internationalization. Integrating a robust i18n system into modern applications powered by Bun, React, TypeScript, and Prisma requires clean separation across three layers:
- Static UI Interface: Fixed labels, buttons, form validations, and navigation titles.
- Compile-Time Type Safety: TypeScript verification of dictionary keys during build time, eliminating runtime string typos.
- Dynamic Database Content: Normalized Prisma schema relationships for multilingual content (such as blog posts, articles, and product catalogs).
Here is the complete implementation across every layer.
1. Installing dependencies with Bun#
bun add i18next react-i18next i18next-browser-languagedetector
i18next: Core localization engine handling resources, language switching, and interpolation.react-i18next: Official React bindings offering theuseTranslationhook and context provider.i18next-browser-languagedetector: Automatic locale discovery vialocalStorage,navigator.language, and browser headers.
2. Directory structure and dictionary schema#
Organize translation dictionaries by locale in src/locales/:
src/
└── locales/
├── en/
│ └── translation.json
└── pt-BR/
└── translation.json
Master reference dictionary (src/locales/en/translation.json):#
{
"welcome": "Welcome to my App",
"description": "Built with Bun and React",
"auth": {
"login": "Log in",
"logout": "Log out",
"email_placeholder": "Enter your email"
},
"dashboard": {
"title": "Dashboard",
"stats": {
"total_users": "Total Users"
}
}
}
Localized dictionary (src/locales/pt-BR/translation.json):#
{
"welcome": "Bem-vindo ao meu App",
"description": "Construído com Bun e React",
"auth": {
"login": "Entrar",
"logout": "Sair",
"email_placeholder": "Digite seu e-mail"
},
"dashboard": {
"title": "Painel",
"stats": {
"total_users": "Total de Usuários"
}
}
}
3. I18n engine initialization (src/i18n.ts)#
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslations from './locales/en/translation.json';
import ptBrTranslations from './locales/pt-BR/translation.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslations },
'pt-BR': { translation: ptBrTranslations }
},
fallbackLng: 'en',
interpolation: {
escapeValue: false // React natively sanitizes output strings against XSS
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage']
}
});
export default i18n;
Import this engine in your entry point (src/main.tsx):
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
4. Strict typing with typescript declaration merging#
Create src/@types/i18next.d.ts to provide editor autocompletion and compile-time key validation:
import 'i18next';
import en from '../locales/en/translation.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: typeof en;
};
}
}
Any invalid key lookup (such as t('auth.logon')) triggers a build error immediately.
5. Component integration with usetranslation#
import { useTranslation } from 'react-i18next';
export function Header() {
const { t, i18n } = useTranslation();
return (
<header>
<h1>{t('welcome')}</h1>
<p>{t('description')}</p>
<button onClick={() => i18n.changeLanguage(i18n.language === 'en' ? 'pt-BR' : 'en')}>
{i18n.language === 'en' ? 'Mudar para Português' : 'Switch to English'}
</button>
</header>
);
}
6. Dynamic database content modeling with prisma#
For entities requiring database-level translations:
// prisma/schema.prisma
model Post {
id Int @id @default(autoincrement())
slug String @unique
createdAt DateTime @default(now())
translations PostTranslation[]
}
model PostTranslation {
id Int @id @default(autoincrement())
postId Int
lang String // e.g., "pt-BR", "en"
title String
content String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, lang])
}
Querying with fallback strategy:#
export async function getPost(slug: string, lang: string) {
const post = await prisma.post.findUnique({
where: { slug },
include: {
translations: {
where: { lang: { in: [lang, 'en'] } }
}
}
});
const translation = post?.translations.find(t => t.lang === lang)
|| post?.translations.find(t => t.lang === 'en');
return { ...post, translation };
}
Best practices for multilingual applications#
- Establish a master dictionary: Treat
en/translation.jsonas the single source of truth for TypeScript typings. - Decouple static from dynamic data: Manage UI labels in JSON files and dynamic records via normalized Prisma database relations.
- Persist user preferences: Utilizing
localStorageprevents locale resets when users navigate across views.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments