Building a Dynamic Multilanguage System Without Rebuilds

Published: (February 8, 2026 at 09:53 AM EST)
14 min read
Source: Dev.to

Source: Dev.to

[![Kuldeep Modi](https://dev-to-uploads.s3.amazonaws.com/uploads/user/profile_image/3745434/4a22fa23-c0dc-4b92-9fe4-8c564a38c522.webp)](https://dev.to/kuldeep-modi)

# How I implemented runtime language switching using database‑driven translations in Nest.js and Next.js

![Dynamic Multilanguage System](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5spp3jch0615wc6emcih.png)

*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.

LayerTechnologyResponsibilities
BackendNest.js• Loads translations into an in‑memory cache
• Serves translations via a controller
Admin PanelRedux (React)• State management for translation CRUD operations
• Enables non‑technical users to create/update translations
FrontendNext.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?

IssueFix
Missing imports & OnModuleInit interfaceAdded import statements and implemented OnModuleInit
Generic type syntax errors (Map>)Replaced with proper Map<string, Map<string, string>>
Inconsistent naming & missing type annotationsAdded explicit types for variables and method parameters
Lack of documentationAdded JSDoc comments for public methods
Unclear cache‑timestamp handlingClarified purpose and made it optional (Date | undefined)
Minor readability concernsReformatted loops, added destructuring, and used nullish coalescing (??)
Overall markdown formattingKept 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

  1. 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.

  2. 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.

  3. Namespace Support
    Organize translations into namespaces (e.g., common, auth, dashboard) to keep large projects manageable and load only what’s needed.

  4. Type Safety
    While translation values are dynamic, keys can be typed with TypeScript’s template‑literal types for IDE autocomplete and compile‑time validation.

  5. Fallback Mechanism
    Missing translations fall back to the key itself, guaranteeing the UI never breaks due to incomplete language files.

Performance Optimizations

TechniqueDescription
Bulk LoadingFetch all translations for a language in a single API call.
Client‑Side CachingCache translations in the browser’s memory after the first fetch.
Lazy LoadingRequest only the translations needed for the current language/namespace.
CDN CachingCache 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/about vs /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.

0 views
Back to Blog

Related posts

Read more »