From lokalise-pack
Implements Lokalise reference architecture for i18n in Node.js web apps, including project layout, CI/CD translation flows, and OTA/build-time tradeoffs.
npx claudepluginhub jeremylongshore/claude-code-plugins-plus-skills --plugin lokalise-packThis skill is limited to using the following tools:
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.
Sets up Lokalise local dev loop: i18n structure, TS exports, push/pull scripts, auto-sync watching, React/Vue integrations, mocks, pre-commit hooks.
Adds full internationalization (i18n) to Next.js App Router projects using next-intl, with SEO-friendly locale routing, hreflang sitemaps, 14+ languages, and bulk translation.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Share bugs, ideas, or general feedback.
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.
@lokalise/node-api SDK installed (npm install @lokalise/node-api)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:
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
// 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;
// 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());
}
}
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:
module.section.elementuser_profile, not userProfilecheckout.payment.card_label_title, _label, _button, _error, _placeholderFile naming:
en.json, de.json, fr.jsoncommon.json, auth.json, dashboard.jsonlocales/en/common.json, locales/en/auth.jsonWhen 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.
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();
}
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
After applying this skill, the project will have:
| 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 |
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.
lokalise-ci-integration to automate the upload/download cycle in CIlokalise-multi-env-setup for per-environment project isolationlokalise-prod-checklist before launching to validate coverage and security