Lokalise Reference Architecture
Overview
A production-ready architecture for integrating Lokalise into web applications. Covers the end-to-end translation flow from source code through CI/CD and Lokalise to deployed translations, recommended project structure for i18n, file organization conventions, multi-app translation sharing, and the tradeoffs between OTA (over-the-air) and build-time translation loading.
Prerequisites
- Node.js 18+ with TypeScript
@lokalise/node-apiSDK installed (npm install @lokalise/node-api)- An i18n framework selected (i18next, react-intl, vue-i18n, or equivalent)
- Lokalise project created with at least one source language configured
- Basic understanding of CI/CD pipelines (GitHub Actions, GitLab CI, or equivalent)
Instructions
Step 1: Architecture Diagram
The translation lifecycle follows this flow:
┌─────────────┐ ┌──────────┐ ┌─────────────┐
│ Source Code │────▶│ CI/CD │────▶│ Lokalise │
│ │ │ (upload) │ │ (TMS) │
│ en.json │ └──────────┘ │ │
│ t('key') │ │ ┌────────┐ │
└─────────────┘ │ │Transla-│ │
│ │ tors │ │
│ └────────┘ │
┌─────────────┐ ┌──────────┐ │ │
│ Deploy │◀────│ CI/CD │◀────│ Download │
│ │ │ (build) │ │ │
│ CDN/Server │ └──────────┘ └─────────────┘
└──────┬──────┘ ┌─────────────┐
│ │ Lokalise │
│ (OTA path) │ OTA CDN │
│◀────────────────────────────│ │
│ └─────────────┘
┌──────▼──────┐
│ Users │
│ (browser/ │
│ mobile) │
└─────────────┘
Two delivery paths:
- Build-time (solid arrows): Translations downloaded during CI build, bundled into the application. Changes require a new deployment.
- OTA (dashed arrow): Translations fetched from Lokalise CDN at runtime. Changes appear without redeployment. Adds a network dependency.
Step 2: Project Structure for i18n
Organize your codebase to separate translation concerns from business logic:
project-root/
├── src/
│ ├── i18n/
│ │ ├── index.ts # i18n initialization and configuration
│ │ ├── client.ts # Lokalise API client wrapper
│ │ ├── loader.ts # Translation loader (build-time or OTA)
│ │ ├── fallback.ts # Fallback translation logic
│ │ ├── types.ts # TypeScript types for translation keys
│ │ └── middleware.ts # Express/Next.js locale detection middleware
│ │
│ ├── locales/
│ │ ├── en.json # Source language (committed to git)
│ │ ├── de.json # Downloaded from Lokalise (gitignored or committed)
│ │ ├── fr.json
│ │ ├── es.json
│ │ └── ja.json
│ │
│ ├── locales-fallback/ # Static fallback copy (always committed)
│ │ ├── en.json
│ │ ├── de.json
│ │ └── ...
│ │
│ └── components/
│ └── ... # Components use t('key') from i18n
│
├── scripts/
│ ├── lokalise-pull.sh # Download translations from Lokalise
│ ├── lokalise-push.sh # Upload source strings to Lokalise
│ ├── validate-translations.ts # Check coverage, placeholders, format
│ └── generate-types.ts # Generate TypeScript types from en.json
│
├── .github/workflows/
│ ├── lokalise-upload.yml # Upload on push to main
│ └── lokalise-download.yml # Download during build
│
└── lokalise.config.ts # Lokalise project configuration
Step 3: Core Configuration
// src/i18n/index.ts
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next'; // or vue-i18n, svelte-i18n, etc.
import en from '../locales/en.json';
export const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'ja'] as const;
export type SupportedLocale = typeof SUPPORTED_LOCALES[number];
export const DEFAULT_LOCALE: SupportedLocale = 'en';
i18next
.use(initReactI18next)
.init({
resources: { en: { translation: en } },
lng: DEFAULT_LOCALE,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...SUPPORTED_LOCALES],
load: 'languageOnly', // 'de' not 'de-DE'
returnEmptyString: false, // Treat '' as missing → use fallback
interpolation: { escapeValue: false },
detection: {
order: ['cookie', 'navigator', 'htmlTag'],
caches: ['cookie'],
},
});
export default i18next;
Step 4: Lokalise API Client Wrapper
// src/i18n/client.ts
import { LokaliseApi } from '@lokalise/node-api';
interface LokaliseClientConfig {
apiToken: string;
projectId: string;
rateLimitPerSec?: number;
}
export class LokaliseClient {
private api: LokaliseApi;
private projectId: string;
private requestTimestamps: number[] = [];
private maxRequestsPerSec: number;
constructor(config: LokaliseClientConfig) {
this.api = new LokaliseApi({ apiKey: config.apiToken });
this.projectId = config.projectId;
this.maxRequestsPerSec = config.rateLimitPerSec ?? 6;
}
/**
* Download all translation files as a zip bundle URL.
*/
async downloadTranslations(options?: {
format?: string;
branch?: string;
}): Promise<string> {
await this.rateLimit();
const projectId = options?.branch
? `${this.projectId}:${options.branch}`
: this.projectId;
const response = await this.api.files().download(projectId, {
format: options?.format ?? 'json',
original_filenames: true,
directory_prefix: '',
export_empty_as: 'base',
export_sort: 'first_added',
});
return response.bundle_url;
}
// Additional methods: listKeys(), getStatistics(), uploadFile()
// follow the same pattern — call this.rateLimit() before each API call.
/**
* Simple rate limiter: 6 requests per second max.
*/
private async rateLimit(): Promise<void> {
const now = Date.now();
this.requestTimestamps = this.requestTimestamps.filter(t => now - t < 1000);
if (this.requestTimestamps.length >= this.maxRequestsPerSec) {
const oldestInWindow = this.requestTimestamps[0];
const waitMs = 1000 - (now - oldestInWindow);
if (waitMs > 0) {
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
this.requestTimestamps.push(Date.now());
}
}
Step 5: File Organization Conventions
Follow these conventions for translation file organization:
Flat keys (recommended for most projects — simpler grep, no nesting ambiguity):
{
"homepage.hero.title": "Welcome to MyApp",
"homepage.hero.subtitle": "The best app ever",
"settings.profile.name_label": "Full Name",
"errors.not_found": "Page not found"
}
Nested keys work better for large projects with clear module boundaries, where each top-level key maps to a feature area. Both formats are supported by Lokalise and i18next.
Key naming conventions:
- Use dot notation:
module.section.element - Use snake_case for key segments:
user_profile, notuserProfile - Prefix by feature area:
checkout.payment.card_label - Use consistent suffixes:
_title,_label,_button,_error,_placeholder - Keep keys under 100 characters (Lokalise hard limit is 1024 chars per key name)
File naming:
- One file per locale:
en.json,de.json,fr.json - For large apps, split by namespace:
common.json,auth.json,dashboard.json - Namespace files go in subdirectories:
locales/en/common.json,locales/en/auth.json
Step 6: Multi-App Translation Sharing
When multiple applications share translations (e.g., web app + mobile app + marketing site):
Lokalise Project: "MyCompany Shared"
├── Tags: shared, web-only, mobile-only, marketing-only
│
├── Shared keys (tag: shared)
│ ├── common.button.ok
│ ├── common.button.cancel
│ └── common.error.generic
│
├── Web-only keys (tag: web-only)
│ ├── web.nav.dashboard
│ └── web.nav.settings
│
└── Mobile-only keys (tag: mobile-only)
├── mobile.nav.home
└── mobile.permissions.camera
Download by tag to get only the keys each app needs:
# Web app — download shared + web-only
lokalise2 file download \
--token "$LOKALISE_API_TOKEN" \
--project-id "$LOKALISE_PROJECT_ID" \
--format json \
--filter-tags "shared,web-only" \
--original-filenames=false \
--bundle-structure "locales/%LANG_ISO%.json" \
--unzip-to "./"
# Mobile app — download shared + mobile-only
lokalise2 file download \
--token "$LOKALISE_API_TOKEN" \
--project-id "$LOKALISE_PROJECT_ID" \
--format json \
--filter-tags "shared,mobile-only" \
--original-filenames=false \
--bundle-structure "src/translations/%LANG_ISO%.json" \
--unzip-to "./"
Alternative: Separate projects with key linking. Lokalise does not natively share keys across projects, so tag-based filtering within a single project is the recommended approach for shared translations.
Step 7: OTA vs Build-Time Translation Loading
Choose the right delivery strategy based on your requirements:
| Factor | Build-Time | OTA | |--------|-----------|-----| | Latency | Zero (bundled) | Network request on first load | | Update speed | Requires deployment | Instant (CDN cache) | | Offline support | Full | Needs initial fetch + local cache | | Bundle size | Increases with locales | Minimal (loaded on demand) | | Reliability | No external dependency | Depends on Lokalise CDN | | Best for | Server-rendered apps, SPAs with CI/CD | Mobile apps, rapid copy changes |
Build-time implementation (recommended for most web apps):
// src/i18n/loader-buildtime.ts
// Translations are imported statically — bundled at build time
import en from '../locales/en.json';
import de from '../locales/de.json';
import fr from '../locales/fr.json';
const translations: Record<string, Record<string, unknown>> = { en, de, fr };
export function loadTranslation(locale: string): Record<string, unknown> {
return translations[locale] ?? translations['en'];
}
OTA implementation (for instant translation updates without redeployment):
// src/i18n/loader-ota.ts
import i18next from 'i18next';
import LocizeBackend from 'i18next-locize-backend'; // or i18next-http-backend
// Lokalise OTA requires the @lokalise/i18next-ota-plugin or a custom backend
// pointing at the Lokalise OTA endpoint.
i18next
.use(LocizeBackend)
.init({
backend: {
// Lokalise OTA SDK endpoint
// See: https://docs.lokalise.com/en/articles/1400697-over-the-air-ota
loadPath: `https://ota.lokalise.com/v3/public/${process.env.LOKALISE_OTA_TOKEN}/{{lng}}/{{ns}}`,
},
fallbackLng: 'en',
ns: ['translation'],
defaultNS: 'translation',
});
Hybrid approach (recommended for production):
// src/i18n/loader-hybrid.ts
import bundledEn from '../locales/en.json';
/**
* Load bundled translations immediately, then attempt OTA update.
* User sees bundled content instantly; OTA updates appear on next render.
*/
export async function loadWithOtaFallback(locale: string): Promise<Record<string, unknown>> {
// 1. Start with bundled translations (instant)
const bundled = await import(`../locales/${locale}.json`)
.then(m => m.default)
.catch(() => bundledEn);
// 2. Attempt OTA fetch in background (non-blocking)
fetchOtaTranslations(locale)
.then(ota => {
if (ota) {
// Merge OTA translations over bundled (OTA wins on conflicts)
Object.assign(i18next.store.data[locale].translation, ota);
i18next.emit('loaded');
}
})
.catch(() => { /* OTA failed, bundled translations are sufficient */ });
return bundled;
}
async function fetchOtaTranslations(locale: string): Promise<Record<string, unknown> | null> {
const otaToken = process.env.LOKALISE_OTA_TOKEN;
if (!otaToken) return null;
const response = await fetch(`https://ota.lokalise.com/v3/public/${otaToken}/${locale}/translation`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
return response.json();
}
Step 8: TypeScript Type Safety
Generate types from your source locale to get compile-time checks on translation keys:
// scripts/generate-types.ts
import fs from 'fs';
const sourceLocale = JSON.parse(fs.readFileSync('src/locales/en.json', 'utf-8'));
function generateTypes(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) {
keys.push(...generateTypes(value as Record<string, unknown>, fullKey));
} else {
keys.push(` | '${fullKey}'`);
}
}
return keys;
}
const typeContent = `// Auto-generated by scripts/generate-types.ts — do not edit
export type TranslationKey =
${generateTypes(sourceLocale).join('\n')};
`;
fs.writeFileSync('src/i18n/types.ts', typeContent);
console.log('Generated src/i18n/types.ts');
Usage in components:
import type { TranslationKey } from '../i18n/types';
// Type-safe translation function
function t(key: TranslationKey, options?: Record<string, string>): string {
return i18next.t(key, options);
}
t('homepage.hero.title'); // OK
t('homepage.hero.titl'); // TypeScript error: not a valid key
Output
After applying this skill, the project will have:
- A clear architecture showing the translation flow from source code through Lokalise to deployment
- Organized project structure with separated i18n concerns
- Lokalise API client wrapper with built-in rate limiting
- File organization following naming conventions
- Multi-app translation sharing via tag-based downloads
- The appropriate translation loading strategy (build-time, OTA, or hybrid) selected and implemented
- TypeScript type generation for compile-time key validation
Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| Circular imports in i18n module | Importing translations before i18n init | Initialize i18n in a separate module, import lazily |
| Missing locale at runtime | Locale file not included in build | Add all locale files to build config; use dynamic import() |
| Stale translations after deploy | Cache not invalidated | Version your translation bundles or use cache-busting query params |
| Type generation fails | Nested key with array value | Filter out arrays in the type generator; Lokalise should not produce arrays |
| OTA translations flash on load | Bundled translations replaced by OTA after render | Use the hybrid approach: render bundled, merge OTA silently |
| Bundle size too large | All locales bundled statically | Use dynamic imports to load only the active locale |
| Tag-based download returns empty | Misspelled tag name | Verify tags in Lokalise dashboard; tags are case-sensitive |
Examples
Minimal vs Enterprise Setup
Minimal (5 files): src/i18n/index.ts, src/locales/en.json, src/locales/de.json, scripts/lokalise-pull.sh, scripts/lokalise-push.sh.
Enterprise adds: client.ts (Step 4), loader-hybrid.ts (Step 7), fallback.ts, middleware.ts, types.ts (Step 8), locales-fallback/ directory, validate-translations.ts, generate-types.ts, and CI workflows. See the full tree in Step 2.
Resources
- Lokalise Node SDK Documentation
- Lokalise CLI v2 Reference
- Lokalise OTA Documentation
- i18next Documentation
- react-intl Documentation
- vue-i18n Documentation
- Lokalise File Formats
Next Steps
- Set up
lokalise-ci-integrationto automate the upload/download cycle in CI - Configure
lokalise-multi-env-setupfor per-environment project isolation - Run
lokalise-prod-checklistbefore launching to validate coverage and security - Implement the TypeScript type generator as a pre-commit hook to keep types in sync