Claude-code-plugins-plus-skills lokalise-reference-architecture

install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/lokalise-pack/skills/lokalise-reference-architecture" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-lokalise-reference-architecture && rm -rf "$T"
manifest: plugins/saas-packs/lokalise-pack/skills/lokalise-reference-architecture/SKILL.md
source content

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:

  1. Build-time (solid arrows): Translations downloaded during CI build, bundled into the application. Changes require a new deployment.
  2. 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
    , 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:

# 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:

FactorBuild-TimeOTA
LatencyZero (bundled)Network request on first load
Update speedRequires deploymentInstant (CDN cache)
Offline supportFullNeeds initial fetch + local cache
Bundle sizeIncreases with localesMinimal (loaded on demand)
ReliabilityNo external dependencyDepends on Lokalise CDN
Best forServer-rendered apps, SPAs with CI/CDMobile 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

IssueCauseSolution
Circular imports in i18n moduleImporting translations before i18n initInitialize i18n in a separate module, import lazily
Missing locale at runtimeLocale file not included in buildAdd all locale files to build config; use dynamic
import()
Stale translations after deployCache not invalidatedVersion your translation bundles or use cache-busting query params
Type generation failsNested key with array valueFilter out arrays in the type generator; Lokalise should not produce arrays
OTA translations flash on loadBundled translations replaced by OTA after renderUse the hybrid approach: render bundled, merge OTA silently
Bundle size too largeAll locales bundled staticallyUse dynamic imports to load only the active locale
Tag-based download returns emptyMisspelled tag nameVerify 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