Type-safe i18n in react and prisma: validating translation keys at build time with typescript
Back to blog

Type-safe i18n in react and prisma: validating translation keys at build time with typescript

6/7/2026 · 2 min · Development

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:

  1. Static UI Interface: Fixed labels, buttons, form validations, and navigation titles.
  2. Compile-Time Type Safety: TypeScript verification of dictionary keys during build time, eliminating runtime string typos.
  3. 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

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#

  1. Establish a master dictionary: Treat en/translation.json as the single source of truth for TypeScript typings.
  2. Decouple static from dynamic data: Manage UI labels in JSON files and dynamic records via normalized Prisma database relations.
  3. Persist user preferences: Utilizing localStorage prevents locale resets when users navigate across views.

Was this article helpful?

Leave a quick reaction to help prioritize future technical guides:

CC BY-NC

This post is licensed under CC BY-NC.

Comments

Join the discussion below.

0 comments