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-api SDK 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
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
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',
returnEmptyString: false,
interpolation: { : },
: {
: [, , ],
: [],
},
});
i18next;
Step 4: Lokalise API Client Wrapper
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;
}
async downloadTranslations(options?: {
format?: string;
?: ;
}): <> {
.();
projectId = options?.
?
: .;
response = ..().(projectId, {
: options?. ?? ,
: ,
: ,
: ,
: ,
});
response.;
}
(): <> {
now = .();
. = ..( now - t < );
(.. >= .) {
oldestInWindow = .[];
waitMs = - (now - oldestInWindow);
(waitMs > ) {
( (resolve, waitMs));
}
}
..(.());
}
}
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, not userProfile
- 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:
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 "./"
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):
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):
import i18next from 'i18next';
import LocizeBackend from 'i18next-locize-backend';
i18next
.use(LocizeBackend)
.init({
backend: {
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):
import bundledEn from '../locales/en.json';
export async function loadWithOtaFallback(locale: string): Promise<Record<string, unknown>> {
const bundled = await import(`../locales/${locale}.json`)
.then(m => m.default)
.catch(() => bundledEn);
fetchOtaTranslations(locale)
.then(ota => {
if (ota) {
Object.assign(i18next.store.data[locale].translation, ota);
i18next.emit('loaded');
}
})
.catch(() => { });
return bundled;
}
(): <<, > | > {
otaToken = process..;
(!otaToken) ;
response = (, {
: .(),
});
(!response.) ;
response.();
}
Step 8: TypeScript Type Safety
Generate types from your source locale to get compile-time checks on translation keys:
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.(, typeContent);
.();
Usage in components:
import type { TranslationKey } from '../i18n/types';
function t(key: TranslationKey, options?: Record<string, string>): string {
return i18next.t(key, options);
}
t('homepage.hero.title');
t('homepage.hero.titl');
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
Next Steps
- Set up
lokalise-ci-integration to automate the upload/download cycle in CI
- Configure
lokalise-multi-env-setup for per-environment project isolation
- Run
lokalise-prod-checklist before launching to validate coverage and security
- Implement the TypeScript type generator as a pre-commit hook to keep types in sync