Building a Dynamic Multilanguage System Without Rebuilds
Source: Dev.to
[](https://dev.to/kuldeep-modi)
# How I implemented runtime language switching using database‑driven translations in Nest.js and Next.js

*Building a Dynamic Multilanguage System Without Rebuilds*
---
*Source: [Dev.to](https://dev.to/kuldeep-modi/building-a-dynamic-multilanguage-system-without-rebuilds-1fo8)*
The Problem
During a recent project I needed to build a multilanguage system for a talent‑hiring platform.
The challenge wasn’t just supporting multiple languages — it was allowing content updates without rebuilding and redeploying the application. Traditional i18n solutions require code changes and rebuilds, but the client needed non‑technical team members to update translations in real‑time.
Requirements
- Support multiple languages (English, Spanish, French, etc.)
- Allow content updates without code deployments
- Maintain type safety with TypeScript
- Fast performance with minimal database queries
- Support nested translation keys
- Admin panel for managing translations
Architecture Overview
The solution stores translations in a MySQL database, caches them in memory, and provides a REST API for the frontend.
| Layer | Technology | Responsibilities |
|---|---|---|
| Backend | Nest.js | • Loads translations into an in‑memory cache • Serves translations via a controller |
| Admin Panel | Redux (React) | • State management for translation CRUD operations • Enables non‑technical users to create/update translations |
| Frontend | Next.js | • Dynamically fetches translations at runtime • Renders localized UI |
Database Schema
A flexible schema lets us store any translation key, optional namespace, and language.
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* Translation Entity
*
* Stores a single translation string identified by:
* - `language` – e.g. 'en', 'es', 'fr'
* - `key` – e.g. 'home.title', 'button.submit'
* - `namespace` – optional grouping such as 'common', 'auth', 'dashboard'
*
* The combination of `language`, `key`, and `namespace` is unique.
*/
@Entity('translations')
@Index(['language', 'key', 'namespace'], { unique: true })
export class Translation {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 10 })
language: string; // e.g. 'en', 'es', 'fr'
@Column({ type: 'varchar', length: 255 })
key: string; // e.g. 'home.title', 'button.submit'
@Column({ type: 'text' })
value: string; // The actual translated text
@Column({ type: 'varchar', length: 50, nullable: true })
namespace?: string; // Optional grouping
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
Backend Service Layer
The service loads all translations into a Map when the application starts and refreshes the cache every five minutes.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Translation } from './translation.entity';
@Injectable()
export class TranslationService implements OnModuleInit {
/** Cache structure: language → (key → value) */
private translationsCache: Map<string, Map<string, string>> = new Map();
/** Timestamp of the last successful cache refresh */
private cacheTimestamp: Date | undefined;
constructor(
@InjectRepository(Translation)
private readonly translationRepository: Repository<Translation>,
) {}
/** Called by NestJS once the module has been initialized */
async onModuleInit(): Promise<void> {
await this.loadTranslations();
// Reload every 5 minutes to pick up changes
setInterval(() => this.loadTranslations(), 5 * 60 * 1000);
}
/** Pulls all rows from the DB and rebuilds the in‑memory cache */
private async loadTranslations(): Promise<void> {
const allTranslations = await this.translationRepository.find();
/** Temporary map while we build the new cache */
const newCache = new Map<string, Map<string, string>>();
for (const translation of allTranslations) {
const { language, namespace, key, value } = translation;
// Ensure a map exists for the current language
if (!newCache.has(language)) {
newCache.set(language, new Map());
}
// Build the full key – include the namespace if present
const fullKey = namespace ? `${namespace}.${key}` : key;
// Store the translation value
newCache.get(language)!.set(fullKey, value);
}
// Swap the old cache for the freshly built one
this.translationsCache = newCache;
this.cacheTimestamp = new Date();
}
/**
* Retrieves a single translation.
*
* @param language Language code (e.g. "en", "fr")
* @param key Translation key
* @param namespace Optional namespace to prepend to the key
* @returns The translated string, or the original key if not found
*/
getTranslation(
language: string,
key: string,
namespace?: string,
): string {
const langMap = this.translationsCache.get(language);
if (!langMap) {
return key; // Fallback when the language is missing
}
const fullKey = namespace ? `${namespace}.${key}` : key;
return langMap.get(fullKey) ?? key;
}
/**
* Returns **all** translations for a given language as a plain object.
*
* @param language Language code
* @returns Object where each property is `key: value`
*/
getAllTranslations(language: string): Record<string, string> {
const langMap = this.translationsCache.get(language);
if (!langMap) {
return {};
}
const result: Record<string, string> = {};
langMap.forEach((value, key) => {
result[key] = value;
});
return result;
}
}
What was cleaned up?
| Issue | Fix |
|---|---|
Missing imports & OnModuleInit interface | Added import statements and implemented OnModuleInit |
Generic type syntax errors (Map>) | Replaced with proper Map<string, Map<string, string>> |
| Inconsistent naming & missing type annotations | Added explicit types for variables and method parameters |
| Lack of documentation | Added JSDoc comments for public methods |
| Unclear cache‑timestamp handling | Clarified purpose and made it optional (Date | undefined) |
| Minor readability concerns | Reformatted loops, added destructuring, and used nullish coalescing (??) |
| Overall markdown formatting | Kept a single heading, a brief description, and a fenced TypeScript block |
The resulting snippet is ready to be copied into a NestJS project and should compile without type errors.
API Controller
Endpoints expose translation data to the frontend and allow admins to create or update entries.
import {
Controller, Get, Post, Body, Param, Query, UseGuards
} from '@nestjs/common';
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TranslationService } from './translation.service';
import { CreateTranslationDto } from './dto/create-translation.dto';
import { Translation } from './entities/translation.entity';
@Controller('api/translations')
export class TranslationController {
constructor(private readonly translationService: TranslationService) {}
/**
* Get all translations for a specific language.
*/
@Get(':language')
@ApiOperation({ summary: 'Get all translations for a language' })
@ApiResponse({ status: 200, description: 'Translations retrieved successfully' })
async getTranslations(
@Param('language') language: string,
): Promise<Translation[]> {
return this.translationService.getAllTranslations(language);
}
/**
* Get a single translation by language, key and optional namespace.
*/
@Get(':language/:key')
@ApiOperation({ summary: 'Get a specific translation' })
@ApiResponse({ status: 200, description: 'Translation retrieved successfully' })
async getTranslation(
@Param('language') language: string,
@Param('key') key: string,
@Query('namespace') namespace?: string,
): Promise<{ value: string | null }> {
const value = await this.translationService.getTranslation(
language,
key,
namespace,
);
return { value };
}
/**
* Create a new translation or update an existing one.
* Protected – only authenticated admins can call it.
*/
@Post()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create or update a translation' })
@ApiResponse({ status: 201, description: 'Translation created or updated' })
async createOrUpdateTranslation(
@Body() dto: CreateTranslationDto,
): Promise<Translation> {
const existing = await this.translationService.translationRepository.findOne({
where: {
language: dto.language,
key: dto.key,
namespace: dto.namespace ?? null,
},
});
if (existing) {
existing.value = dto.value;
return this.translationService.translationRepository.save(existing);
}
const newTranslation = this.translationService.translationRepository.create(dto);
return this.translationService.translationRepository.save(newTranslation);
}
}
Note: The controller above demonstrates a complete, runtime‑switchable i18n solution that lets non‑technical users manage translations without triggering a rebuild.
Backend Service (NestJS)
src/translation/translation.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Translation } from './translation.entity';
import { CreateTranslationDto } from './dto/create-translation.dto';
@Injectable()
export class TranslationService {
/** In‑memory cache: key → { translationKey: translationValue } */
private cache = new Map<string, Record<string, string>>();
constructor(
@InjectRepository(Translation)
private readonly translationRepository: Repository<Translation>,
) {}
/** Load all translations from the DB into the cache */
async loadTranslations(): Promise<void> {
const translations = await this.translationRepository.find();
const grouped = translations.reduce<Record<string, Record<string, string>>>(
(acc, t) => {
const cacheKey = `${t.language}.${t.namespace ?? 'common'}`;
if (!acc[cacheKey]) acc[cacheKey] = {};
acc[cacheKey][t.key] = t.value;
return acc;
},
{},
);
this.cache.clear();
Object.entries(grouped).forEach(([k, v]) => this.cache.set(k, v));
}
/** Get translations for a language (and optional namespace) */
getTranslations(
language: string,
namespace?: string,
): Record<string, string> {
const cacheKey = `${language}.${namespace ?? 'common'}`;
return this.cache.get(cacheKey) ?? {};
}
/** Refresh the cache – can be called after an admin edit */
async refreshCache(): Promise<void> {
await this.loadTranslations();
}
}
src/translation/translation.controller.ts
import { Controller, Get, Param, Query, Post } from '@nestjs/common';
import { TranslationService } from './translation.service';
@Controller('api/translations')
export class TranslationController {
constructor(private readonly translationService: TranslationService) {}
/** GET /api/translations/:lang?namespace=... */
@Get(':lang')
getTranslations(
@Param('lang') lang: string,
@Query('namespace') namespace?: string,
) {
return this.translationService.getTranslations(lang, namespace);
}
/** POST /api/translations/refresh – admin only */
@Post('refresh')
async refreshCache() {
await this.translationService.refreshCache();
return { status: 'ok' };
}
}
src/translation/translation.seeder.ts
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Translation } from './translation.entity';
import { TranslationService } from './translation.service';
@Injectable()
export class TranslationSeeder implements OnApplicationBootstrap {
constructor(
private readonly translationService: TranslationService,
@InjectRepository(Translation)
private readonly repo: Repository<Translation>,
) {}
async onApplicationBootstrap(): Promise<void> {
// Load initial data if the table is empty
const count = await this.repo.count();
if (!count) {
await this.repo.save([
{
language: 'en',
namespace: 'home',
key: 'title',
value: 'Welcome',
},
{
language: 'en',
namespace: 'home',
key: 'subtitle',
value: 'Hello, {{name}}!',
},
// …more seed rows…
]);
}
// Populate the in‑memory cache
await this.translationService.loadTranslations();
}
}
src/translation/translation.admin.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Translation } from './translation.entity';
import { TranslationService } from './translation.service';
import { CreateTranslationDto } from './dto/create-translation.dto';
@Injectable()
export class TranslationAdminService {
constructor(
@InjectRepository(Translation)
private readonly repo: Repository<Translation>,
private readonly translationService: TranslationService,
) {}
/** Create or update a translation and refresh the cache */
async upsert(dto: CreateTranslationDto): Promise<Translation> {
const existing = await this.repo.findOne({
where: {
language: dto.language,
namespace: dto.namespace,
key: dto.key,
},
});
if (existing) {
existing.value = dto.value;
await this.repo.save(existing);
await this.translationService.loadTranslations(); // Refresh cache
return existing;
}
const translation = this.repo.create(dto);
await this.repo.save(translation);
await this.translationService.loadTranslations(); // Refresh cache
return translation;
}
}
Key improvements
- Fixed generic typings for
Map,Repository, and reducer accumulator. - Added missing imports (
Injectable,Controller, etc.). - Explicit return types (
Promise<void>,Record<string, string>, etc.) for better type safety. - Cleaned up comments and spacing while preserving the original logic.
Frontend Hook (Next.js)
useTranslation Hook
import { useState, useEffect, useCallback } from 'react';
interface UseTranslationOptions {
/** ISO language code, e.g. "en", "fr", … */
language: string;
/** Optional namespace to scope the keys (e.g. "home", "dashboard") */
namespace?: string;
}
/**
* Fetches translations from the API and returns a `t` function that
* resolves a key (optionally with interpolation) to the appropriate string.
*/
export function useTranslation({
language,
namespace,
}: UseTranslationOptions) {
// Store the raw translation map returned by the API.
const [translations, setTranslations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTranslations = async () => {
try {
const response = await fetch(
`/api/translations/${language}${
namespace ? `?namespace=${namespace}` : ''
}`,
);
const data = await response.json();
setTranslations(data);
} catch (error) {
console.error('Failed to load translations:', error);
} finally {
setLoading(false);
}
};
fetchTranslations();
}, [language, namespace]);
/**
* Resolve a translation key.
*
* @param key The translation key (without namespace prefix).
* @param params Optional interpolation parameters, e.g. `{ name: 'John' }`.
* @returns The translated string, or the key itself if missing.
*/
const t = useCallback(
(key: string, params?: Record<string, string | number>): string => {
const fullKey = namespace ? `${namespace}.${key}` : key;
let translation = translations[fullKey] ?? key;
// Simple `{{param}}` interpolation.
if (params) {
Object.entries(params).forEach(([paramKey, value]) => {
const pattern = new RegExp(`{{${paramKey}}}`, 'g');
translation = translation.replace(pattern, String(value));
});
}
return translation;
},
[translations, namespace],
);
return { t, loading, translations };
}
Usage in Components
'use client';
import { useTranslation } from '@/hooks/useTranslation';
import { useLanguage } from '@/contexts/LanguageContext';
export function WelcomeBanner() {
const { language } = useLanguage();
const { t, loading } = useTranslation({ language, namespace: 'home' });
if (loading) {
return <p>Loading…</p>;
}
return (
<section className="welcome-banner">
<h1>{t('title')}</h1>
<p>{t('subtitle', { name: 'John' })}</p>
</section>
);
}
The hook now has proper TypeScript typings, clearer comments, and the example component includes a complete JSX return.
Here’s the same content with clean, consistent markdown formatting. The heading is kept as a markdown heading, and the JSX fragment is placed inside a properly‑indented code block.
## {t('title')}
```jsx
{t('subtitle', { name: 'John' })}
{t('button.cta')}
);
}
## Language Context Provider
```tsx
'use client';
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
interface LanguageContextType {
language: string;
setLanguage: (lang: string) => void;
availableLanguages: string[];
}
/* Provide a sensible default so TypeScript knows the shape of the context.
It will be overwritten by the provider at runtime. */
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export function LanguageProvider({
children,
}: {
children: ReactNode;
}) {
const [language, setLanguageState] = useState<string>('en');
// Load saved language from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('preferred-language');
if (saved) {
setLanguageState(saved);
}
}, []);
const setLanguage = (lang: string) => {
setLanguageState(lang);
localStorage.setItem('preferred-language', lang);
// Optionally reload the page to fetch new translations
window.location.reload();
};
const availableLanguages = ['en', 'es', 'fr', 'de'];
return (
<LanguageContext.Provider
value={{ language, setLanguage, availableLanguages }}
>
{children}
</LanguageContext.Provider>
);
}
/**
* Custom hook for consuming the language context.
* Throws an error if used outside of a LanguageProvider.
*/
export function useLanguage(): LanguageContextType {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}
Key Features
-
In‑Memory Caching
Translations are loaded once on server startup and kept in memory, eliminating DB queries for each request and delivering sub‑millisecond response times. -
Automatic Cache Refresh
The cache refreshes every 5 minutes, so updates appear quickly without a restart. Critical changes can be pushed instantly via the admin panel. -
Namespace Support
Organize translations into namespaces (e.g.,common,auth,dashboard) to keep large projects manageable and load only what’s needed. -
Type Safety
While translation values are dynamic, keys can be typed with TypeScript’s template‑literal types for IDE autocomplete and compile‑time validation. -
Fallback Mechanism
Missing translations fall back to the key itself, guaranteeing the UI never breaks due to incomplete language files.
Performance Optimizations
| Technique | Description |
|---|---|
| Bulk Loading | Fetch all translations for a language in a single API call. |
| Client‑Side Caching | Cache translations in the browser’s memory after the first fetch. |
| Lazy Loading | Request only the translations needed for the current language/namespace. |
| CDN Caching | Cache public API responses at the CDN edge for extra speed. |
Admin Panel Integration
The Metronic‑styled admin panel (built with Redux) enables non‑technical users to:
- View all translations in a searchable table.
- Edit translations inline.
- Add new languages.
- Filter by namespace or missing keys.
- Trigger an immediate cache refresh after saving changes.
When a translation is updated, it is persisted to the database and the backend cache is refreshed automatically.
Results
- Zero downtime for translation updates.
- Sub‑second translation retrieval times.
- Easy content management for non‑technical team members.
- Scalability to handle hundreds of languages and thousands of keys.
Multilingual System Architecture
(Add your architecture diagram, description, or code snippets here.)
## Type Safety with TypeScript Support
Are you building a multilingual system like this?
[Let’s discuss architecture and edge cases.](mailto:kuldeepmodi95@gmail.com)
---
Lessons Learned
- Cache invalidation is crucial – The automatic refresh mechanism ensures updates propagate quickly without manual intervention.
- Design for scale – Even if you start with two languages, design the system to handle 20+ languages from the beginning.
- Provide fallbacks – Never let missing translations break the UI; always have a fallback strategy.
- Monitor translation coverage – Track which keys are missing translations for each language to ensure completeness.
- Consider SEO – For public‑facing pages, ensure URLs reflect the language (e.g.,
/en/aboutvs/es/about) for better SEO.
Conclusion
Building a dynamic multilanguage system without rebuilds requires careful architecture, but the benefits are significant. By storing translations in a database, caching them efficiently, and providing a clean API, we created a system that’s both performant and flexible.
- The ability to update content without deployments has been a game‑changer for the team, allowing faster iterations and better content management.
- This approach works well for applications that need frequent content updates, multiple languages, and non‑technical content managers.
- For simpler use cases, traditional i18n libraries might be sufficient, but for enterprise applications, this database‑driven approach provides the flexibility needed.
Originally published at kuldeepmodi.vercel.app.