# Better I18N — Full Documentation > Every published page, one file. Index: https://help.better-i18n.com/hi/llms.txt --- # Introduction Source: https://help.better-i18n.com/hi/docs/introduction Better i18n is an **AI-powered localization platform** for engineering teams. It delivers translations via a global CDN, provides AI-assisted translation with human approval, and optionally integrates with GitHub for PR-based workflows. ## Getting Started Choose your integration path: - [Next.js](/frameworks/nextjs) — Get started with Next.js - [Vite](/frameworks/vite) — Get started with Vite - [TanStack Start](/frameworks/tanstack-start) — Get started with TanStack Start - [Remix / Hydrogen](/frameworks/remix) — CDN-powered i18n for Remix and Shopify Hydrogen - [Expo / React Native](/frameworks/expo) — iOS and Android apps with offline caching and device locale detection. ## Core Features - **Automatic Key Discovery**: Scans your codebase to find translation keys - **AI-Powered Translation**: Suggests translations with human approval workflow - **GitHub Integration**: Pull request workflows for translation updates - **CDN Delivery**: Global edge network for fast translation loading - **Developer Tools**: CLI, MCP server, and framework SDKs ## Tools & Integrations - [Content SDK](/sdk) — Headless CMS client for fetching content - [CLI](/cli) — Command-line interface for Better i18n - [MCP Server](/mcp) — Model Context Protocol integration --- # Core Source: https://help.better-i18n.com/hi/docs/core ## Overview `@better-i18n/core` is the foundation package that powers all Better i18n SDKs. It provides a **framework-agnostic API** for fetching translations, discovering languages, managing caches, and handling locale URLs. Use `@better-i18n/core` directly when: - You're building a **custom integration** outside Next.js, Vite, TanStack, or Expo - You need to **fetch available languages** with metadata (name, native name, flags) - You're writing **server-side scripts** or **CLI tools** that interact with the CDN - You want **locale URL utilities** for routing in any framework > [!NOTE] > If you're using Next.js, Vite, TanStack Start, or Expo, you don't need to install `@better-i18n/core` directly — it's included as a dependency of each framework SDK. ## Install ```bash tab="npm" npm install @better-i18n/core ``` ```bash tab="yarn" yarn add @better-i18n/core ``` ```bash tab="pnpm" pnpm add @better-i18n/core ``` ```bash tab="bun" bun add @better-i18n/core ``` ## Quick Start Create an i18n core instance and start fetching translations: ```ts title="i18n.ts" import { createI18nCore } from "@better-i18n/core"; const i18n = createI18nCore({ projectId: "my-company/web-app", defaultLocale: "en", }); // Fetch translation messages for a locale const messages = await i18n.getMessages("tr"); // { common: { welcome: "Hoş geldiniz", logout: "Çıkış" } } // Get all available locale codes const locales = await i18n.getLocales(); // ["en", "tr", "de", "fr"] // Get languages with full metadata (name, native name, flag) const languages = await i18n.getLanguages(); ``` ## Common Use Cases ### Fetch Available Languages The most common use case — building a language switcher or listing available translations: ```ts import { createI18nCore } from "@better-i18n/core"; const i18n = createI18nCore({ projectId: "my-company/web-app", defaultLocale: "en", }); const languages = await i18n.getLanguages(); languages.forEach((lang) => { console.log(`${lang.code}: ${lang.nativeName} ${lang.isDefault ? "(default)" : ""}`); }); ``` ### Fetch the Manifest The manifest contains all project metadata — languages, files, timestamps: ```ts const manifest = await i18n.getManifest(); console.log(manifest.sourceLanguage); // "en" console.log(manifest.languages); // Full language metadata console.log(manifest.files); // CDN file URLs and sizes console.log(manifest.updatedAt); // Last update timestamp ``` ### Pre-load Translations Fetch messages for multiple locales at once (useful for SSR or static generation): ```ts const locales = await i18n.getLocales(); const allMessages = await Promise.all( locales.map(async (locale) => ({ locale, messages: await i18n.getMessages(locale), })) ); ``` ## Configuration ```ts const i18n = createI18nCore({ // Required projectId: "org/project", // Project identifier defaultLocale: "en", // Fallback locale // Optional cdnBaseUrl: "https://cdn.better-i18n.com", // Custom CDN URL manifestCacheTtlMs: 300_000, // Cache TTL (default: 5 min) debug: false, // Enable debug logging logLevel: "warn", // "debug" | "info" | "warn" | "error" | "silent" fetch: customFetch, // Custom fetch (for testing) }); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `projectId` | `string` | Required | Project ID — accepts `org/project` slug or canonical UUID | | `defaultLocale` | `string` | Required | Fallback locale code | | `cdnBaseUrl` | `string` | `"https://cdn.better-i18n.com"` | CDN base URL | | `manifestCacheTtlMs` | `number` | `300000` (5 min) | Manifest cache TTL in ms | | `debug` | `boolean` | `false` | Enable debug logging | | `logLevel` | `LogLevel` | `"warn"` | Log level threshold | | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch function | ## Next Steps - Explore the [API Reference](/core/api-reference) for all methods and types - Learn about [Locale URL Utilities](/core/locale-utilities) for routing - See framework-specific guides: [Next.js](/frameworks/nextjs), [Vite](/frameworks/vite), [TanStack Start](/frameworks/tanstack-start), [Expo](/frameworks/expo) --- # How It Works Source: https://help.better-i18n.com/hi/docs/core/how-it-works ## Overview ``` ┌─────────────────────────────────────────────────────────────┐ │ Better i18n Platform │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │ │ Dashboard │ → │ Storage │ → │ CDN │ │ │ │ /GitHub │ │ (Origin) │ │ (Edge) │ │ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │ ↓ │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────┐ │ Your Application │ │ (React, Next.js, etc.) │ └─────────────────────────────┘ ``` ## CDN URL Structure All resources follow a predictable URL pattern: ``` https://cdn.better-i18n.com/{org}/{project}/{resource} ``` ### Resources | Resource | URL | Description | |----------|-----|-------------| | Manifest | `/org/project/manifest.json` | Available languages | | Messages | `/org/project/{locale}.json` | Translations for a locale | | Flags | `/flags/{code}.svg` | Country flag images | ### Examples ``` https://cdn.better-i18n.com/acme/webapp/manifest.json https://cdn.better-i18n.com/acme/webapp/en.json https://cdn.better-i18n.com/acme/webapp/tr.json https://cdn.better-i18n.com/flags/us.svg ``` ## The Manifest Every project has a `manifest.json` that describes available languages: ```json title="manifest.json" { "defaultLocale": "en", "locales": ["en", "tr", "de", "es"], "languages": [ { "code": "en", "name": "English", "nativeName": "English", "flagUrl": "https://cdn.better-i18n.com/flags/en.svg" }, { "code": "tr", "name": "Turkish", "nativeName": "Türkçe", "flagUrl": "https://cdn.better-i18n.com/flags/tr.svg" } ] } ``` The manifest powers: - `useLanguages()` hook for building language switchers - Automatic locale validation - Default locale fallback ## Translation Files Each locale has its own JSON file with namespaced translations: ```json title="en.json" { "home": { "title": "Welcome to our app", "description": "The best app ever built" }, "common": { "save": "Save", "cancel": "Cancel" } } ``` ### Namespaced Delivery Projects with many translation files (marketing sites, documentation portals) can use **namespaced delivery** — one file per namespace per locale: ``` https://cdn.better-i18n.com/acme/landing/en/common.json https://cdn.better-i18n.com/acme/landing/en/hero.json https://cdn.better-i18n.com/acme/landing/en/pricing.json ``` With namespaced delivery enabled, the manifest exposes two extra fields: ```json { "batch": true, "namespaces": ["common", "hero", "pricing", "footer"], "files": { "en": { "url": ".../translations.json", ... } } } ``` - `batch: true` — CDN supports `/{locale}/batch.json?ns=a,b,c` for multi-namespace requests in a single round-trip - `namespaces: string[]` — authoritative list for SDK validation The SDK fetches only the namespaces a page needs instead of the entire bundle. See [Selective Loading](/core/selective-loading) for configuration and performance details. --- ## Caching Strategy The CDN uses aggressive caching with smart invalidation: | Resource | Cache Duration | Invalidation | |----------|----------------|--------------| | `manifest.json` | 5 minutes | On publish | | `{locale}.json` | 1 hour | On publish | | Flag images | 1 year | Never (immutable) | ### Cache Headers ```http # Manifest Cache-Control: public, max-age=300, s-maxage=300 # Messages Cache-Control: public, max-age=3600, s-maxage=3600 # Flags Cache-Control: public, max-age=31536000, immutable ``` ## Publishing When you publish translations: 1. New files are uploaded to origin storage 2. CDN cache is purged globally 3. New requests hit origin, then cache at edge 4. Subsequent requests served from edge cache ``` Publish → Purge Cache → First Request (Origin) → Cache at Edge → Subsequent Requests (Edge) ``` ### Purge Timing Cache purge propagates globally within **5-10 seconds**. During this window, some users may see old content. ## Edge Locations Translations are served from edge locations worldwide: - North America (US, Canada) - Europe (UK, Germany, France, Netherlands) - Asia Pacific (Japan, Singapore, Australia) - South America (Brazil) Users are routed to the nearest edge location automatically. --- ## URL Strategy How to structure URLs for internationalized applications. Better i18n follows the "default locale without prefix" pattern. ### Default Pattern The default locale has **no prefix**, other locales have prefixes: | Locale | URL | Notes | |--------|-----|-------| | English (default) | `/about` | Clean URL for primary audience | | Turkish | `/tr/about` | Locale prefix | | German | `/de/about` | Locale prefix | ### Benefits **Clean URLs for Primary Audience:** ``` https://example.com/pricing ← Most users https://example.com/tr/pricing ← Turkish users ``` **SEO Friendly:** - Each locale has unique, indexable URLs - Search engines can crawl all versions - `hreflang` tags link translations together ### SEO Considerations #### Hreflang Tags Tell search engines about language alternatives: ```html ``` `x-default` indicates the fallback for unmatched languages. #### Canonical URLs Each page should have a canonical URL: ```html ``` #### Sitemap Include all locale variants in your sitemap: ```xml https://example.com/about ``` --- ## Custom CDN For enterprise deployments, you can use a custom CDN URL: ```tsx ``` ### Self-Hosting To self-host translations: 1. Export JSON files from dashboard 2. Host on your own CDN/server 3. Configure `cdnBaseUrl` in provider ``` https://cdn.your-company.com/org/project/manifest.json https://cdn.your-company.com/org/project/en.json ``` --- ## Preloading For faster initial load, preload translations: ```html ``` This starts the fetch before JavaScript loads. ## Offline Support For offline-first apps, cache translations in a service worker: ```js // sw.js const TRANSLATION_CACHE = 'translations-v1' const TRANSLATION_URLS = [ 'https://cdn.better-i18n.com/org/project/manifest.json', 'https://cdn.better-i18n.com/org/project/en.json', 'https://cdn.better-i18n.com/org/project/tr.json', ] self.addEventListener('install', (event) => { event.waitUntil( caches.open(TRANSLATION_CACHE).then((cache) => { return cache.addAll(TRANSLATION_URLS) }) ) }) self.addEventListener('fetch', (event) => { if (event.request.url.includes('cdn.better-i18n.com')) { event.respondWith( caches.match(event.request).then((response) => { return response || fetch(event.request) }) ) } }) ``` --- ## Troubleshooting ### Stale Translations If translations aren't updating: 1. Check cache headers with browser DevTools 2. Try hard refresh (Ctrl+Shift+R) 3. Verify publish completed in dashboard ### CORS Errors The CDN sets appropriate CORS headers: ```http Access-Control-Allow-Origin: * ``` If you see CORS errors, check if you're using a custom CDN without proper headers. ### Slow Initial Load If translations load slowly: 1. Use preload links 2. Check edge location (use CDN debug headers) 3. Consider server-side rendering ## Related - [Provider](/frameworks/provider) - Client-side setup - [Server Utilities](/frameworks/server) - Server-side fetching - [TanStack SSR](/frameworks/tanstack-start/ssr) - SSR integration --- # Webhooks Source: https://help.better-i18n.com/hi/docs/core/webhooks ## Overview Webhooks let your application react instantly to translation events — no polling required. When something changes in Better i18n (a publish, a new key, a language addition), the platform sends an HTTP `POST` to your endpoint within seconds. **Common use cases:** - **Next.js ISR revalidation** — trigger `revalidateTag("translations")` on `translations.published` - **Cache invalidation** — clear your Redis/Varnish/CDN cache when translations update - **CI/CD pipelines** — kick off a deploy when new translations are approved - **Slack/Discord notifications** — alert your team when a sync completes - **Static-site rebuilds** — trigger a GitHub Actions `repository_dispatch` on `content.entry.published` to rebuild a site whose data comes from the Content CMS --- ## Setting Up an Endpoint 1. Go to your project → **Integrations** → **Webhooks** 2. Click **+ Add Webhook** 3. Enter your endpoint URL and select the events you want to receive 4. Copy and save the **webhook secret** — it's only shown once > [!WARNING] > The webhook secret is displayed **only once** at creation time. Store it immediately in your environment variables. If you lose it, you can regenerate it from the Webhooks page — but all existing integrations using the old secret will stop verifying until updated. --- ## Payload Structure Every webhook request has the same envelope format: ```json { "id": "evt_01jfk2abcd...", "webhookConfigId": "wh_01jfk2...", "eventType": "translations.published", "timestamp": 1734567890123, "createdAt": "2024-12-19T10:31:30.123Z", "version": "1", "data": { // event-specific fields (see Events below) } } ``` | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Stable event id (`evt_*`). Use as an idempotency key — if you see the same id twice, treat it as the same event (e.g. on manual redelivery). | | `webhookConfigId` | `string` | ID of the webhook endpoint that fired this event | | `eventType` | `string` | One of the event types listed below | | `timestamp` | `number` | Unix milliseconds when the event was dispatched | | `createdAt` | `string` | ISO-8601 timestamp, same moment as `timestamp` | | `version` | `string` | Envelope version — currently `"1"` | | `data` | `object` | Event-specific payload | --- ## Request Headers ```http POST /api/webhooks/i18n HTTP/1.1 Content-Type: application/json X-Better-I18n-Signature: t=1734567890,v1=a1b2c3...,sha256=d4e5f6... X-Better-I18n-Event: translations.published X-Better-I18n-Id: evt_01jfk2abcd... ``` | Header | Description | |--------|-------------| | `X-Better-I18n-Signature` | Dual-format HMAC signature — see below | | `X-Better-I18n-Event` | The event type (mirrors `payload.eventType`) | | `X-Better-I18n-Id` | The stable event id (mirrors `payload.id`) | ### Signature format The signature header is a comma-separated list of components, Stripe-style: ``` t=,v1=,sha256= ``` - **`t`** — unix seconds when the payload was signed. Your handler **must** reject requests whose `t` is older than ~5 minutes (replay protection). - **`v1`** — HMAC-SHA256 of the string `${t}.${body}`. This is the current signature scheme; it binds the timestamp into the signature so an attacker can't replay a captured request with a fresh `t`. - **`sha256`** — HMAC-SHA256 of just the body. Legacy scheme, retained for backward compatibility with pre-v1 integrations. New consumers should ignore this component and verify `v1`. --- ## Verifying Signatures **Always verify the signature** before processing a webhook. This proves the request came from Better i18n and hasn't been tampered with. The signature is computed as: ``` HMAC-SHA256(secret, rawRequestBody) ``` and sent as `sha256=` in the `X-Better-I18n-Signature` header. > [!WARNING] > Use a **timing-safe comparison** (`timingSafeEqual` in Node.js, `crypto.subtle` in the browser/edge). A regular string `===` check is vulnerable to timing attacks. ### Next.js App Router ```ts title="app/api/webhooks/i18n/route.ts" import { createHmac, timingSafeEqual } from "crypto"; const WEBHOOK_SECRET = process.env.BETTER_I18N_WEBHOOK_SECRET!; const TOLERANCE_SECONDS = 300; function verifySignature(body: string, header: string): boolean { const parts = Object.fromEntries( header.split(",").map((p) => { const [k, ...rest] = p.split("="); return [k, rest.join("=")]; }), ); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", WEBHOOK_SECRET) .update(`${t}.${body}`) .digest("hex"); return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); } export async function POST(req: Request) { const body = await req.text(); const signature = req.headers.get("x-better-i18n-signature") ?? ""; if (!verifySignature(body, signature)) { return new Response("Unauthorized", { status: 401 }); } const event = JSON.parse(body); if (event.eventType === "translations.published") { // Revalidate Next.js ISR cache const { revalidateTag } = await import("next/cache"); revalidateTag("translations"); } return new Response("OK"); } ``` ### Edge Runtime (Cloudflare Workers / Vercel Edge) ```ts title="webhook-handler.ts" async function verifySignature( secret: string, body: string, header: string, ): Promise { const parts = Object.fromEntries( header.split(",").map((p) => { const [k, ...rest] = p.split("="); return [k, rest.join("=")]; }), ); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const sigBytes = new Uint8Array( parts.v1.match(/.{2}/g)!.map((b) => parseInt(b, 16)), ); return crypto.subtle.verify( "HMAC", key, sigBytes, new TextEncoder().encode(`${t}.${body}`), ); } export default { async fetch(req: Request, env: Env) { const body = await req.text(); const signature = req.headers.get("x-better-i18n-signature") ?? ""; const valid = await verifySignature( env.BETTER_I18N_WEBHOOK_SECRET, body, signature, ); if (!valid) return new Response("Unauthorized", { status: 401 }); const event = JSON.parse(body); // handle event... return new Response("OK"); }, }; ``` ### Node.js / Express ```ts title="webhook.ts" import express from "express"; import { createHmac, timingSafeEqual } from "crypto"; const app = express(); // IMPORTANT: use raw body — parsed JSON loses byte-for-byte accuracy app.post( "/webhooks/i18n", express.raw({ type: "application/json" }), (req, res) => { const body = req.body.toString(); const header = req.headers["x-better-i18n-signature"] as string; const parts = Object.fromEntries( header.split(",").map((p) => { const [k, ...rest] = p.split("="); return [k, rest.join("=")]; }), ); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) { return res.status(401).send("Unauthorized"); } if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) { return res.status(401).send("Stale request"); } const expected = createHmac("sha256", process.env.BETTER_I18N_WEBHOOK_SECRET!) .update(`${t}.${body}`) .digest("hex"); if (!timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) { return res.status(401).send("Bad signature"); } const event = JSON.parse(body); // handle event... res.status(200).send("OK"); }, ); ``` > [!NOTE] > In Express, use `express.raw()` (not `express.json()`) before verifying signatures. `express.json()` parses the body first, which can alter whitespace and break signature verification. --- ## Events ### `translations.published` Fired after translations are published to the CDN (or committed to GitHub). This is the primary event for triggering cache revalidation. ```json { "webhookConfigId": "wh_01jfk2...", "eventType": "translations.published", "timestamp": 1734567890123, "data": { "org": "acme", "project": "webapp", "languages": ["tr", "de", "fr"], "publishedAt": "2024-12-19T10:31:30.123Z", "keysCount": 42 } } ``` | Field | Description | |-------|-------------| | `org` | Organization slug | | `project` | Project slug | | `languages` | Language codes that were published | | `publishedAt` | ISO 8601 timestamp | | `keysCount` | Number of translation keys published | ### `translations.updated` Fired when a translation value is edited in the dashboard (not yet published). ```json { "eventType": "translations.updated", "data": { "org": "acme", "project": "webapp", "language": "tr", "keysCount": 5 } } ``` ### `keys.created` Fired when new translation keys are added to a project. ```json { "eventType": "keys.created", "data": { "org": "acme", "project": "webapp", "keys": ["auth.login.title", "auth.login.submit"], "namespace": "auth" } } ``` ### `keys.deleted` Fired when translation keys are removed from a project. ```json { "eventType": "keys.deleted", "data": { "org": "acme", "project": "webapp", "keys": ["deprecated.old_key"], "namespace": "deprecated" } } ``` ### `sync.completed` Fired when a GitHub sync job finishes (either success or failure). ```json { "eventType": "sync.completed", "data": { "org": "acme", "project": "webapp", "status": "completed", "keysImported": 128, "duration": 4200 } } ``` ### `language.added` Fired when a new target language is added to a project. ```json { "eventType": "language.added", "data": { "org": "acme", "project": "webapp", "language": "ja", "languageName": "Japanese" } } ``` ### `language.removed` Fired when a target language is removed from a project. ```json { "eventType": "language.removed", "data": { "org": "acme", "project": "webapp", "language": "pt" } } ``` --- ## Content CMS Events If your project uses the Content CMS (`@better-i18n/sdk`), every mutation emits a webhook. Use these to rebuild static sites on publish, invalidate downstream caches, or notify teams when structural changes happen. ### Shared fields All `content.*` events carry the same base envelope plus a standard set of fields: | Field | Type | Description | |-------|------|-------------| | `event` | `string` | Event name (same as `eventType` in the outer envelope) | | `timestamp` | `string` (ISO 8601) | When the mutation occurred | | `org` | `string` | Organization slug | | `project` | `string` | Project slug | | `model` | `string \| null` | Content model slug (e.g. `"blog-post"`) | | `actor` | `object \| null` | Who triggered the mutation | | `actor.userId` | `string` | User id (or agent id for `mcp`) | | `actor.via` | `"ui" \| "mcp" \| "api" \| "system"` | Where the mutation came from | The `actor.via` field lets downstream consumers filter by provenance — for example, only rebuilding when a human publishes (`ui`) and ignoring bulk agent corrections (`mcp`). --- ### `content.entry.created` Fired when a new entry is inserted into a content model. ```json { "eventType": "content.entry.created", "data": { "event": "content.entry.created", "timestamp": "2026-04-15T10:00:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entry": { "id": "ent_01abc", "slug": "hello-world", "status": "draft", "previousStatus": null, "publishedAt": null, "sourceLanguageCode": "en" }, "languages": ["en"], "changedLanguages": ["en"], "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` ### `content.entry.updated` Fired when an entry is edited and the status does NOT change. If the edit also transitions the status, a `content.entry.published` or `content.entry.unpublished` event is emitted instead. ```json { "eventType": "content.entry.updated", "data": { "event": "content.entry.updated", "timestamp": "2026-04-15T10:05:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entry": { "id": "ent_01abc", "slug": "hello-world", "status": "draft", "previousStatus": "draft", "publishedAt": null, "sourceLanguageCode": "en" }, "languages": ["en", "tr"], "changedLanguages": ["tr"], "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` The `changedLanguages` array tells you **which languages were touched by this mutation** — use it to scope cache invalidation instead of flushing every locale. ### `content.entry.published` Fired when an entry transitions to `status: "published"`. This is the event to subscribe to for most static-site rebuild workflows. ```json { "eventType": "content.entry.published", "data": { "event": "content.entry.published", "timestamp": "2026-04-15T10:10:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entry": { "id": "ent_01abc", "slug": "hello-world", "status": "published", "previousStatus": "draft", "publishedAt": "2026-04-15T10:10:00.000Z", "sourceLanguageCode": "en" }, "languages": ["en", "tr"], "changedLanguages": [], "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` > [!NOTE] > `previousStatus` lets you distinguish first-publishes (`"draft" → "published"`) from re-publishes (`"published" → "published"`). Useful for welcome emails, social announcements, etc. ### `content.entry.unpublished` Fired when a published entry reverts to `draft` or `archived`. ```json { "eventType": "content.entry.unpublished", "data": { "event": "content.entry.unpublished", "timestamp": "2026-04-15T10:20:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entry": { "id": "ent_01abc", "slug": "hello-world", "status": "draft", "previousStatus": "published", "publishedAt": null, "sourceLanguageCode": "en" }, "languages": ["en", "tr"], "changedLanguages": [], "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` ### `content.entry.deleted` Fired when an entry is hard-deleted. The payload carries the entry slug so you can purge it from downstream caches. ```json { "eventType": "content.entry.deleted", "data": { "event": "content.entry.deleted", "timestamp": "2026-04-15T10:30:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entry": { "id": "ent_01abc", "slug": "hello-world", "status": null, "previousStatus": null, "publishedAt": null, "sourceLanguageCode": null }, "languages": ["en", "tr"], "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` ### `content.entry.bulkPublished` / `bulkUpdated` / `bulkDeleted` Bulk mutations fire **two sets of events**: 1. A single rollup (`bulkPublished` / `bulkUpdated` / `bulkDeleted`) with the full list of entries in one payload. 2. N per-entry events — one `content.entry.published` / `.unpublished` / `.updated` / `.deleted` per affected entry. This lets you subscribe granularly (e.g., only `content.entry.published` for per-entry CDN purge) or coarsely (`content.entry.bulkPublished` for one rebuild no matter how many entries are in the batch). ```json { "eventType": "content.entry.bulkPublished", "data": { "event": "content.entry.bulkPublished", "timestamp": "2026-04-15T10:00:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "entries": [ { "id": "ent_1", "slug": "post-a", "previousStatus": "draft", "status": "published" }, { "id": "ent_2", "slug": "post-b", "previousStatus": "published", "status": "published" } ], "count": 2, "languages": ["en", "tr"], "actor": { "userId": "mcp-agent", "via": "mcp" } } } ``` `bulkUpdated` may mix per-entry transitions — each row in `entries[]` carries its own `previousStatus` / `status` so you can tell which entries newly became published vs. reverted vs. just edited. --- ### `content.model.created` / `updated` / `deleted` Fired when a content model (the schema for a content type — e.g. "Blog Post") is created, modified, or deleted. Model mutations are relatively rare but have a large blast radius; subscribe if your application type-generates from the schema. ```json { "eventType": "content.model.created", "data": { "event": "content.model.created", "timestamp": "2026-04-15T09:00:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` --- ### `content.field.added` / `updated` / `deleted` Fired when a custom field is added to, modified on, or removed from a content model. The `field` block describes the field's identity and localization flag. ```json { "eventType": "content.field.added", "data": { "event": "content.field.added", "timestamp": "2026-04-15T09:30:00.000Z", "org": "acme", "project": "marketing-site", "model": "blog-post", "field": { "name": "hero_image", "type": "media", "localized": false }, "actor": { "userId": "usr_xyz", "via": "ui" } } } ``` --- ## Responding to Webhooks Your endpoint must return a `2xx` status code within **10 seconds**, otherwise the delivery is marked as failed. - Return `200 OK` (or any `2xx`) to acknowledge receipt - Do **not** perform long-running work synchronously — offload to a queue - Better i18n does **not** retry failed deliveries automatically, but you can manually redeliver from the Webhooks page --- ## Delivery Logs Every delivery attempt is logged in the dashboard under **Integrations → Webhooks → Delivery Log**. For each entry you can see: - HTTP status code - Event type - Delivery timestamp - Response body (first 500 characters) You can click **Redeliver** on any past delivery to resend the exact same payload. --- ## Managing the Secret Your webhook secret is used to sign all outgoing payloads. Keep it in environment variables — never hardcode it. ```bash # .env.local BETTER_I18N_WEBHOOK_SECRET=whsec_a1b2c3d4... ``` If you need to rotate the secret (e.g., after a potential leak), click **Regenerate Secret** in the Webhooks settings. The new secret is shown once — update your environment variables before closing the dialog. > [!WARNING] > After regenerating, all deliveries signed with the old secret will fail verification until you deploy the new secret to your application. --- ## Recipe: Rebuild a static site on content publish A common pattern for Content CMS consumers is bridging a Better i18n webhook to a GitHub Actions `repository_dispatch`, which rebuilds the site on demand: ``` Content CMS publish → content.entry.published webhook → your webhook handler (Vercel Function, CF Worker, etc.) → POST /repos/OWNER/REPO/dispatches (GitHub REST API) → GitHub Actions rebuilds + deploys ``` Minimal bridge (runs on any edge or Node runtime): ```ts title="webhook-bridge.ts" async function handler(req: Request) { const body = await req.text(); const sig = req.headers.get("x-better-i18n-signature") ?? ""; if (!await verifySignature(SECRET, body, sig)) { return new Response("Unauthorized", { status: 401 }); } const event = JSON.parse(body); // Only rebuild on actual publishes of blog posts const shouldRebuild = (event.eventType === "content.entry.published" || event.eventType === "content.entry.bulkPublished") && event.data.model === "blog-post"; if (!shouldRebuild) return new Response("OK"); await fetch( "https://api.github.com/repos/YOUR_ORG/YOUR_REPO/dispatches", { method: "POST", headers: { Authorization: `Bearer ${GITHUB_DISPATCH_TOKEN}`, Accept: "application/vnd.github+json", }, body: JSON.stringify({ event_type: "content-updated", client_payload: { model: event.data.model, entrySlug: event.data.entry?.slug, via: event.data.actor?.via, }, }), }, ); return new Response("Dispatched"); } ``` And the matching workflow: ```yaml title=".github/workflows/deploy-on-content.yml" on: repository_dispatch: types: [content-updated] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run build - run: bun run deploy ``` Filter by `event.data.actor?.via === "ui"` if you only want to rebuild on human-triggered publishes (skipping MCP / API bulk corrections). --- # Core API Reference Source: https://help.better-i18n.com/hi/docs/core/api-reference Complete API reference for the core package. All framework SDKs (Next.js, Vite, TanStack Start, Expo) are built on top of these primitives. ## createI18nCore Creates an i18n core instance for fetching translations, languages, and manifests from the CDN. ```ts import { createI18nCore } from "@better-i18n/core"; const i18n = createI18nCore({ projectId: "org/project", defaultLocale: "en", }); ``` ### Instance Methods Fetches translation messages for a specific locale from the CDN. ```ts // Full fetch — all namespaces const messages = await i18n.getMessages("tr"); // { common: { welcome: "Hoş geldiniz" }, auth: { login: "Giriş Yap" } } // Selective — only fetch these namespaces (namespaced_folders projects) const subset = await i18n.getMessages("tr", { namespaces: ["common", "hero"], }); // { common: {...}, hero: {...} } ``` | Parameter | Type | Description | | --- | --- | --- | | `locale` | `string` | Locale code (e.g., `"en"`, `"tr"`, `"de"`) | | `options.namespaces` | `string[]?` | Fetch only these namespaces. Silently ignored for `single_file` projects. | **Returns:** `Promise` — Translation key-value pairs > [!NOTE] > When `options.namespaces` is provided on a batch-enabled project (`manifest.batch === true`), the SDK collapses multiple namespace requests into a single HTTP call and caches each namespace individually for cross-page reuse. See [Selective Loading](/core/selective-loading) for the full picture. Returns all available locale codes for the project. ```ts const locales = await i18n.getLocales(); // ["en", "tr", "de", "fr"] ``` **Returns:** `Promise` — Array of locale codes Returns available languages with full metadata — ideal for building language switchers and locale pickers. ```ts const languages = await i18n.getLanguages(); // [ // { code: "en", name: "English", nativeName: "English", isDefault: true }, // { code: "tr", name: "Turkish", nativeName: "Türkçe", flagUrl: "https://..." }, // { code: "de", name: "German", nativeName: "Deutsch", flagUrl: "https://..." }, // ] ``` **Returns:** `Promise` — Array of language objects with metadata | Property | Type | Description | | --- | --- | --- | | `code` | `string` | Language code (e.g., `"en"`, `"tr"`) | | `name` | `string?` | English name (e.g., `"Turkish"`) | | `nativeName` | `string?` | Native name (e.g., `"Türkçe"`) | | `flagUrl` | `string \| null` | URL to flag icon | | `isDefault` | `boolean?` | Whether this is the source/default language | Fetches the project manifest from CDN. The manifest contains all project metadata including languages, files, and timestamps. ```ts // Use cached manifest (default) const manifest = await i18n.getManifest(); // Force a fresh fetch (bypass cache) const fresh = await i18n.getManifest({ forceRefresh: true }); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `forceRefresh` | `boolean` | `false` | Skip cache and fetch fresh | **Returns:** `Promise` ```ts interface ManifestResponse { projectSlug?: string; sourceLanguage?: string; languages: ManifestLanguage[]; files?: Record; updatedAt?: string; } ``` The resolved configuration with all defaults applied. ```ts console.log(i18n.config.cdnBaseUrl); // "https://cdn.better-i18n.com" console.log(i18n.config.manifestCacheTtlMs); // 300000 console.log(i18n.config.workspaceId); // "org" console.log(i18n.config.projectSlug); // "project" ``` --- ## clearManifestCache Clears the global manifest cache across all instances. Useful for testing or forcing a fresh fetch. ```ts import { clearManifestCache } from "@better-i18n/core"; clearManifestCache(); ``` --- ## extractLanguages Extracts and normalizes language information from a raw manifest response. Useful when you have a manifest and need to convert it to UI-friendly language options. ```ts import { extractLanguages } from "@better-i18n/core"; const manifest = await i18n.getManifest(); const languages = extractLanguages(manifest); // [{ code: "en", name: "English", nativeName: "English", isDefault: true }, ...] ``` | Parameter | Type | Description | | --- | --- | --- | | `manifest` | `ManifestResponse` | Raw manifest from CDN | **Returns:** `LanguageOption[]` — Normalized language options > [!TIP] > In most cases, use `i18n.getLanguages()` instead — it handles fetching the manifest and extracting languages in one call. --- ## TtlCache Generic in-memory cache with automatic TTL expiration. Used internally for manifest caching, but available for custom use. ```ts import { TtlCache } from "@better-i18n/core"; const cache = new TtlCache(); // Store with 60s TTL cache.set("key", "value", 60_000); // Retrieve (returns undefined if expired) const value = cache.get("key"); // "value" // Check existence cache.has("key"); // true // Manual removal cache.delete("key"); // Clear all entries cache.clear(); ``` ### Methods | Method | Signature | Description | | --- | --- | --- | | `get` | `(key: string) => T \| undefined` | Get value (auto-deletes if expired) | | `set` | `(key: string, value: T, ttlMs: number) => void` | Store with TTL in ms | | `has` | `(key: string) => boolean` | Check if key exists and is not expired | | `delete` | `(key: string) => boolean` | Remove a key | | `clear` | `() => void` | Clear all entries | --- ## detectLocale Framework-agnostic locale detection with priority-based selection. Detects the best locale from path, cookie, and header sources. ```ts import { detectLocale } from "@better-i18n/core"; const result = detectLocale({ pathLocale: "tr", cookieLocale: "en", headerLocale: "de", defaultLocale: "en", availableLocales: ["en", "tr", "de"], project: "org/project", }); console.log(result.locale); // "tr" console.log(result.detectedFrom); // "path" console.log(result.shouldSetCookie); // true ``` ### Detection Priority 1. **Path** — Locale from URL (e.g., `/tr/about`) 2. **Cookie** — Stored user preference 3. **Header** — Browser's `Accept-Language` header 4. **Default** — Fallback to `defaultLocale` ### Options | Option | Type | Description | | --- | --- | --- | | `project` | `string` | Project identifier | | `defaultLocale` | `string` | Fallback locale | | `pathLocale` | `string \| null` | Locale from URL path | | `cookieLocale` | `string \| null` | Locale from cookie | | `headerLocale` | `string \| null` | Locale from Accept-Language | | `availableLocales` | `string[]` | Supported locale codes | ### Result | Property | Type | Description | | --- | --- | --- | | `locale` | `string` | Detected locale code | | `detectedFrom` | `"path" \| "cookie" \| "header" \| "default"` | Detection source | | `shouldSetCookie` | `boolean` | Whether to update the locale cookie | --- ## Configuration Utilities Normalizes user configuration by applying defaults and validating required fields. ```ts import { normalizeConfig } from "@better-i18n/core"; const config = normalizeConfig({ projectId: "org/project", defaultLocale: "en", }); console.log(config.cdnBaseUrl); // "https://cdn.better-i18n.com" console.log(config.manifestCacheTtlMs); // 300000 console.log(config.workspaceId); // "org" console.log(config.projectSlug); // "project" ``` **Throws** if `projectId` or `defaultLocale` is empty or invalid format. Parses a project identifier string into its components. ```ts import { parseProject } from "@better-i18n/core"; const parsed = parseProject("acme/dashboard"); // { workspaceId: "acme", projectSlug: "dashboard" } ``` **Throws** if format is not `"org/project"`. Builds the full CDN base URL for a project. ```ts import { normalizeConfig, getProjectBaseUrl } from "@better-i18n/core"; const config = normalizeConfig({ projectId: "acme/dashboard", defaultLocale: "en" }); const url = getProjectBaseUrl(config); // "https://cdn.better-i18n.com/acme/dashboard" ``` Creates a unique cache key for manifest caching. ```ts import { buildCacheKey } from "@better-i18n/core"; const key = buildCacheKey("https://cdn.better-i18n.com", "acme/dashboard"); // "https://cdn.better-i18n.com|acme/dashboard" ``` --- ## createLogger Creates a namespaced logger instance with level filtering. ```ts import { createLogger, normalizeConfig } from "@better-i18n/core"; const config = normalizeConfig({ projectId: "org/project", defaultLocale: "en", debug: true, }); const logger = createLogger(config, "my-module"); logger.debug("loading translations"); // [better-i18n:my-module] loading translations logger.info("ready"); // [better-i18n:my-module] ready logger.warn("cache miss"); // [better-i18n:my-module] cache miss logger.error("fetch failed"); // [better-i18n:my-module] fetch failed ``` ### Log Levels | Level | Value | Description | | --- | --- | --- | | `"debug"` | 0 | All messages | | `"info"` | 1 | Info and above | | `"warn"` | 2 | Warnings and above (default) | | `"error"` | 3 | Errors only | | `"silent"` | 4 | No output | --- ## Types All types are exported from `@better-i18n/core`: ```ts import type { // Configuration I18nCoreConfig, NormalizedConfig, ParsedProject, // Manifest ManifestResponse, ManifestLanguage, ManifestFile, LanguageOption, // Messages Messages, Locale, // Instance I18nCore, // Cache CacheEntry, // Logger Logger, LogLevel, // Locale URL utilities LocaleConfig, // Middleware/Detection I18nMiddlewareConfig, LocaleDetectionOptions, LocaleDetectionResult, LocalePrefix, } from "@better-i18n/core"; ``` User-provided configuration for `createI18nCore`. ```ts interface I18nCoreConfig { projectId: string; // "org/project" slug or canonical UUID (required) project?: string; // @deprecated — use projectId (kept for backward compat) defaultLocale: string; // Fallback locale (required) cdnBaseUrl?: string; // Default: "https://cdn.better-i18n.com" manifestCacheTtlMs?: number; // Default: 300000 (5 min) debug?: boolean; // Default: false logLevel?: LogLevel; // Default: "warn" fetch?: typeof fetch; // Custom fetch function } ``` `projectId` accepts either an `org/project` slug or a canonical UUID (e.g., `"2cc52ff1-5eb4-41a5-85d6-34ad6fade788"`). Passing the UUID makes CDN URLs stable across slug renames — find it in dashboard Settings → General → Project ID. CDN manifest response structure. ```ts interface ManifestResponse { projectSlug?: string; sourceLanguage?: string; languages: ManifestLanguage[]; files?: Record; updatedAt?: string; /** CDN supports batch namespace fetching via /{locale}/batch.json?ns=... */ batch?: boolean; /** Top-level namespace list for namespaced_folders projects */ namespaces?: string[]; } ``` The `batch` and `namespaces` fields appear only for `namespaced_folders` projects served by CDN workers that support batching. See [Selective Loading](/core/selective-loading) for how the SDK uses them. Language entry in manifest. ```ts interface ManifestLanguage { code: string; // "en", "tr", "de" name?: string; // "Turkish" nativeName?: string; // "Türkçe" flagUrl?: string | null; // Flag icon URL isSource?: boolean; // Source language flag lastUpdated?: string | null; // Last update timestamp keyCount?: number; // Number of translation keys } ``` Simplified language option for UI components. ```ts interface LanguageOption { code: string; // "tr" name?: string; // "Turkish" nativeName?: string; // "Türkçe" flagUrl?: string | null; // Flag icon URL isDefault?: boolean; // Source/default language } ``` Instance returned by `createI18nCore()`. ```ts interface I18nCore { config: NormalizedConfig; getManifest(options?: { forceRefresh?: boolean }): Promise; getMessages( locale: string, options?: { namespaces?: string[] }, ): Promise; getLocales(): Promise; getLanguages(): Promise; } ``` Result from `detectLocale()`. ```ts interface LocaleDetectionResult { locale: string; detectedFrom: "path" | "cookie" | "header" | "default"; shouldSetCookie: boolean; } ``` --- # Selective Loading Source: https://help.better-i18n.com/hi/docs/core/selective-loading For projects with **namespaced file structure** (`fileStructure: "namespaced_folders"`), Better i18n can fetch only the namespaces a page actually uses instead of the whole translation bundle. This is powered by three layers: 1. **Selective fetch** — `getMessages(locale, { namespaces })` requests a subset 2. **Per-namespace caching** — each namespace caches individually; cross-page navigations reuse shared namespaces 3. **Batch endpoint (tRPC-style)** — N namespace requests collapse into 1 HTTP round-trip No configuration required on the consumer side if the project's CDN announces batch support (`manifest.batch === true`) — the SDK uses it automatically. ## Why It Matters A marketing site with **103 namespaces × 22 locales** has a combined translation file around **500KB**. Most pages only need 5–10 namespaces: | Scenario | Requests | Bytes over the wire | | --- | --- | --- | | Full bundle (every locale, every page) | 1 | ~500KB | | Selective (batch on) — first visit | 1 | ~30KB | | Selective — second page (cache reuse) | 0–1 | 0–5KB | The savings compound as a user navigates multiple pages; shared namespaces (`common`, `navigation`, `footer`) are fetched once and reused. ## Basic Usage ```ts import { createI18nCore } from "@better-i18n/core"; const i18n = createI18nCore({ projectId: "acme/landing", defaultLocale: "en", }); // Full fetch — all namespaces (legacy behavior) const all = await i18n.getMessages("en"); // Selective — only these namespaces const page = await i18n.getMessages("en", { namespaces: ["common", "hero", "pricing"], }); ``` The second signature is a **no-op for projects that don't use `namespaced_folders`** — passing `namespaces` to a single-file project is silently ignored (the SDK returns the combined file as usual). ## How It Works ### 1. Selective Fetch When `namespaces` is provided, the SDK: 1. Reads the manifest (cached, ~3KB compressed) 2. Validates requested namespaces against `manifest.namespaces` 3. Fetches only the requested set from the CDN Non-existent namespaces are skipped silently — requesting `["common", "nonexistent"]` returns just `{ common: {...} }` without errors. ### 2. Per-Namespace Caching Each namespace is stored under an individual cache key: ``` {cdnBaseUrl}|{project}|{locale}|ns:common {cdnBaseUrl}|{project}|{locale}|ns:navigation {cdnBaseUrl}|{project}|{locale}|ns:footer ``` Compare this to a composite cache key (`ns:common,navigation,footer`) — with composite keys, navigating to a different page with different namespaces would always be a cache miss. With per-namespace caching, shared namespaces are reused across navigations: ``` Home needs: [common, hero, pricing] → 3 cache writes Blog needs: [common, navigation, blog] → common hits cache, 2 new fetches Changelog needs: [common, navigation, changelog] → common + navigation hit cache, 1 fetch ``` After 3–4 pages, most navigations require **zero** CDN requests. ### 3. Batch Endpoint (When Available) If `manifest.batch === true`, the SDK collapses multiple uncached namespace requests into a **single** HTTP request via `/{locale}/batch.json?ns=...`: ``` GET https://cdn.better-i18n.com/acme/landing/en/batch.json?ns=common,hero,pricing → { "common": {...}, "hero": {...}, "pricing": {...} } ``` Behavior: - Single namespace → direct fetch (batch overhead isn't worth it for one file) - Two or more uncached namespaces → single batch request - Batch fails → automatic fallback to parallel individual fetches - Fresh responses are split into per-namespace cache entries (see above) The batch endpoint is cached at the CDN edge with a **version-based cache key** so publishes invalidate automatically without having to enumerate every possible namespace combination. ## Validating Your Project Not every project has `namespaced_folders`. Verify via the manifest: ```bash curl -s https://cdn.better-i18n.com/acme/landing/manifest.json \ | jq '{ batch, namespaces }' ``` Expected output for a batch-enabled project: ```json { "batch": true, "namespaces": ["auth", "blog", "common", "footer", "hero", "navigation", "pricing"] } ``` If `batch` is absent or `false`, the SDK still supports selective loading via parallel individual fetches — just without the single-request optimization. ## Production Behavior ### Staleness | Layer | TTL | Invalidation | | --- | --- | --- | | SDK TtlCache (per-namespace) | 60s default, `messagesCacheTtlMs` configurable | Implicit on TTL expiry | | CDN edge cache (batch response) | 60s | Version bump on publish | | CDN edge cache (individual file) | 60s | Purge fires on publish | After publish, stale translations surface within ~60s at most — same as the non-batch path. ### Fallback Chain Selective loading respects the same fallback chain as the full-fetch path: ``` 1. TtlCache (per-namespace) ↓ miss 2. Batch endpoint (if manifest.batch === true and 2+ uncached) ↓ fail 3. Individual parallel fetches ↓ fail 4. Persistent storage (if configured) ↓ fail 5. staticData ↓ fail 6. Throw ``` Each step preserves previously-fetched namespaces — a partial batch failure doesn't invalidate namespaces already served from cache. ## When NOT to Use Selective Loading - **Small projects (< 20 namespaces total)** — the combined file is small enough that selective fetches don't meaningfully reduce bytes. - **Single-file projects** (`fileStructure: "single_file"`) — the feature is silently ignored, but there's no performance benefit either. - **Offline-first apps** — prefer full fetch + `staticData` fallback so every namespace is bundled for offline availability. For most SaaS apps, **full fetch is fine**. Selective loading is a marketing-site and multi-page-app optimization. ## Related - [API Reference — `getMessages`](/core/api-reference#getmessages) — signature and options - [How It Works](/core/how-it-works) — CDN architecture and purge flow - [TanStack Start — Selective Loading Example](/frameworks/tanstack-start/selective-loading) — end-to-end loader pattern from the `better-i18n` landing page --- # Locale URL Utilities Source: https://help.better-i18n.com/hi/docs/core/locale-utilities `@better-i18n/core` includes a set of locale-aware URL utilities that work with any framework. These are the same utilities used internally by the Next.js and TanStack Start SDKs. > [!NOTE] > **Convention:** The default locale has **no URL prefix** (e.g., `/about` for English), while non-default locales are prefixed (e.g., `/tr/about` for Turkish). This follows the same pattern as next-intl and Paraglide JS. ## Setup All utilities require a `LocaleConfig` object: ```ts import type { LocaleConfig } from "@better-i18n/core"; const config: LocaleConfig = { locales: ["en", "tr", "de", "fr"], defaultLocale: "en", }; ``` | Property | Type | Description | | --- | --- | --- | | `locales` | `string[]` | All supported locale codes | | `defaultLocale` | `string` | Default locale (no URL prefix) | --- ## extractLocale Extracts locale from URL pathname's first segment. Returns `null` for the default locale (no prefix). ```ts import { extractLocale } from "@better-i18n/core"; extractLocale("/tr/about", config); // "tr" extractLocale("/de/settings", config); // "de" extractLocale("/about", config); // null (default locale) extractLocale("/", config); // null ``` | Parameter | Type | Description | | --- | --- | --- | | `pathname` | `string` | URL pathname | | `config` | `LocaleConfig` | Locale configuration | **Returns:** `string | null` — Locale code or `null` for default --- ## getLocaleFromPath Gets the effective locale from URL, always returning a string. Unlike `extractLocale`, this never returns `null`. ```ts import { getLocaleFromPath } from "@better-i18n/core"; getLocaleFromPath("/tr/about", config); // "tr" getLocaleFromPath("/about", config); // "en" (default) getLocaleFromPath("/", config); // "en" (default) ``` | Parameter | Type | Description | | --- | --- | --- | | `pathname` | `string` | URL pathname | | `config` | `LocaleConfig` | Locale configuration | **Returns:** `string` — Always a valid locale code --- ## hasLocalePrefix Checks if pathname has a locale prefix in the first segment. ```ts import { hasLocalePrefix } from "@better-i18n/core"; hasLocalePrefix("/tr/about", config); // true hasLocalePrefix("/about", config); // false hasLocalePrefix("/unknown/page", config); // false ``` --- ## removeLocalePrefix Removes locale prefix from pathname. Returns the path unchanged if no locale prefix is present. ```ts import { removeLocalePrefix } from "@better-i18n/core"; removeLocalePrefix("/tr/about", config); // "/about" removeLocalePrefix("/de/settings", config); // "/settings" removeLocalePrefix("/about", config); // "/about" (unchanged) removeLocalePrefix("/tr", config); // "/" ``` --- ## addLocalePrefix Adds locale prefix to pathname. Default locale gets **no prefix** (convention). ```ts import { addLocalePrefix } from "@better-i18n/core"; addLocalePrefix("/about", "tr", config); // "/tr/about" addLocalePrefix("/about", "en", config); // "/about" (default = no prefix) addLocalePrefix("/", "de", config); // "/de" ``` | Parameter | Type | Description | | --- | --- | --- | | `pathname` | `string` | URL pathname | | `locale` | `string` | Target locale | | `config` | `LocaleConfig` | Locale configuration | --- ## replaceLocaleInPath Replaces or adds locale in pathname. The main utility for **locale switching** in URLs. ```ts import { replaceLocaleInPath } from "@better-i18n/core"; // Switch from default to Turkish replaceLocaleInPath("/about", "tr", config); // "/tr/about" // Switch from Turkish to German replaceLocaleInPath("/tr/about", "de", config); // "/de/about" // Switch to default (removes prefix) replaceLocaleInPath("/tr/about", "en", config); // "/about" // Switch between non-default locales replaceLocaleInPath("/de/settings", "fr", config); // "/fr/settings" ``` ### Language Switcher Example ```tsx import { replaceLocaleInPath } from "@better-i18n/core"; import type { LocaleConfig } from "@better-i18n/core"; const config: LocaleConfig = { locales: ["en", "tr", "de"], defaultLocale: "en", }; function LanguageSwitcher({ currentPath }: { currentPath: string }) { return ( ); } ``` --- ## createLocalePath Creates a reusable path builder with fixed configuration. Useful when you need to generate many localized paths. ```ts import { createLocalePath } from "@better-i18n/core"; const localePath = createLocalePath(config); localePath("/about", "tr"); // "/tr/about" localePath("/about", "en"); // "/about" localePath("/about"); // "/about" (uses default locale) ``` | Parameter | Type | Description | | --- | --- | --- | | `config` | `LocaleConfig` | Locale configuration | **Returns:** `(path: string, locale?: string) => string` ### Navigation Example ```tsx import { createLocalePath } from "@better-i18n/core"; const localePath = createLocalePath({ locales: ["en", "tr", "de"], defaultLocale: "en", }); function Navigation({ locale }: { locale: string }) { return ( ); } ``` --- # Frameworks Source: https://help.better-i18n.com/hi/docs/frameworks Better i18n ships first-class **React SDKs** for every major framework, plus native SDKs for mobile — all CDN-first, so translations load from the edge with no build step and update without a redeploy. ## React & web frameworks - [Next.js](/frameworks/nextjs) — App Router + Pages Router, middleware locale routing, RSC-friendly - [TanStack Start](/frameworks/tanstack-start) — full SSR with type-safe routing - [Vite](/frameworks/vite) — client-side React apps and React Router - [Remix & Hydrogen](/frameworks/remix) — loader-based SSR and Shopify Hydrogen storefronts ## Native & mobile - [Expo](/frameworks/expo) — React Native with offline caching and instant language switching - [iOS (Swift)](/frameworks/ios) — reactive translations in SwiftUI - [Flutter](/frameworks/flutter) — native Dart SDK with offline support ## Server - [Server SDK](/frameworks/server-sdk) — runtime-agnostic server-side i18n for any JavaScript backend (Node, Hono, tRPC, Supabase, Better Auth) ## Shared concepts Every framework SDK builds on the same primitives: - [Quick Start](/frameworks/quick-start) — five-minute setup - [Provider](/frameworks/provider) — configure `BetterI18nProvider` - [Translations](/frameworks/translations) — the `t()` function and namespaces - [Locale](/frameworks/locale) — locale detection and switching - [Geo Detection](/frameworks/geo-detection) — infer locale from region - [Formatting](/frameworks/formatting) — numbers, dates, and plurals - [Server Utilities](/frameworks/server) — server-side fetching --- # Quick Start Source: https://help.better-i18n.com/hi/docs/frameworks/quick-start ## Installation ```bash tab="npm" npm install @better-i18n/use-intl use-intl ``` ```bash tab="yarn" yarn add @better-i18n/use-intl use-intl ``` ```bash tab="pnpm" pnpm add @better-i18n/use-intl use-intl ``` ```bash tab="bun" bun add @better-i18n/use-intl use-intl ``` ## Add the Provider Wrap your app with `BetterI18nProvider`: ```tsx title="src/App.tsx" import { BetterI18nProvider } from '@better-i18n/use-intl' // [!code ++] function App() { return ( // [!code ++] // [!code ++] ) } ``` > [!NOTE] > The `project` value uses `org/project` format from your Better i18n dashboard. ## Use Translations Use the `useTranslations` hook in any component: ```tsx title="src/components/Welcome.tsx" import { useTranslations } from '@better-i18n/use-intl' // [!code highlight] export function Welcome() { const t = useTranslations('home') // [!code highlight] return (

{t('title')}

{t('description')}

) } ``` ## That's It! Your app now: - ✅ Fetches translations from the Better i18n CDN - ✅ Supports dynamic language switching - ✅ Auto-discovers available languages from your project --- ## Choose Your Framework Each guide covers framework-specific patterns like SSR, routing, and middleware. - [TanStack Start](/frameworks/tanstack-start) — Full-stack React with SSR support, path-based routing, and middleware. - [Vite + React](/frameworks/vite) — Client-side React applications with Vite bundler. - [Next.js](/frameworks/nextjs) — Deep integration with Next.js App Router via `@better-i18n/next`. - [Remix / Hydrogen](/frameworks/remix) — CDN-powered i18n for Remix and Shopify Hydrogen with loader-based translations. - [Expo / React Native](/frameworks/expo) — iOS and Android with offline caching, device locale detection, and Expo Router integration. ### Framework Comparison | Framework | Rendering | Routing | Best For | |-----------|-----------|---------|----------| | **TanStack Start** | SSR + CSR | File-based | Full-stack React, SEO | | **Vite + React** | CSR | Optional | SPAs, prototypes, landing pages | | **Next.js** | SSR + CSR | App Router | Production apps | | **Remix** | SSR | Loader-based | E-commerce (Hydrogen), full-stack | ### Feature Matrix | Feature | TanStack Start | Vite | Next.js | Remix | |---------|----------------|------|---------|-------| | Server messages | ✅ | ✅ (plugin) | ✅ | ✅ | | Path-based locale | ✅ | ✅ (plugin) | ✅ | ✅ | | Middleware | ✅ | ✅ (plugin) | ✅ | ❌ | | Static generation | ❌ | ❌ | ✅ | ❌ | | Edge runtime | ✅ | ❌ | ✅ | ✅ | | Bundle size | ~2KB | ~2KB | ~5KB | ~2KB | > [!TIP] > **Not sure?** Start with [Vite + React](/frameworks/vite) for the simplest setup, then upgrade to TanStack Start or Next.js when you need SSR. --- ## AI Tooling Let your AI agent manage translations and understand better-i18n conventions automatically. - [MCP Server](/mcp/getting-started) — Connect Cursor, Claude Code, or Windsurf to your project — translate keys, check coverage, and publish directly from your editor. - [Agent Skill](/mcp/agent-skill) — Give your AI agent permanent better-i18n knowledge — SDK patterns, CDN behavior, and key conventions. No prompts needed each session. ```bash # MCP server — tool execution npx -y @better-i18n/mcp@latest # Agent skill — persistent knowledge npx skills add better-i18n/skills ``` --- ## Core Concepts Learn the fundamentals that apply to all frameworks: - [How It Works](/core/how-it-works) — CDN architecture, caching, and URL strategy. - [Provider](/frameworks/provider) — Configure the BetterI18nProvider for your app. - [Translations](/frameworks/translations) — useTranslations hook, namespaces, and interpolation. - [Locale Management](/frameworks/locale) — useLocale, useLanguages, and LanguageSwitcher. - [Formatting](/frameworks/formatting) — Format dates, numbers, and lists with useFormatter. - [Server Utilities](/frameworks/server) — Pre-load messages for SSR and server-side translation. --- # Provider Source: https://help.better-i18n.com/hi/docs/frameworks/provider ## Basic Usage ### With Vite Plugin (zero config) When using [`@better-i18n/vite`](/frameworks/vite/setup), the provider reads everything from the injected ` injection + LS/PS that break HTML parsers return JSON.stringify(value) .replace(//g, "\\u003e") .replace(/&/g, "\\u0026") .replace(/
/g, "\\u2028") .replace(/
/g, "\\u2029"); } function getClientMessages(): Messages | undefined { if (typeof document === "undefined") return undefined; const el = document.getElementById("__i18n_messages__"); if (!el?.textContent) return undefined; try { return JSON.parse(el.textContent) as Messages; } catch { return undefined; } } function RootComponent() { const { locale, requestId } = Route.useRouteContext(); const loaderData = Route.useLoaderData(); const messages = (() => { // SSR: pull from the per-request map, then drop it if (typeof document === "undefined") { const msgs = ssrMessagesByRequest.get(requestId); if (msgs) ssrMessagesByRequest.delete(requestId); return msgs; } // Client: loader data (navigation) > hydration script (first load) return loaderData?.messages ?? getClientMessages(); })(); return ( ); } ``` ## Step 4 — Verify in Dev The SDK logs selective fetches in development. After starting the dev server, navigate to a route and check the console: ``` [i18n] pricing: fetching 4 namespaces (of 103) [common, footer, navigation, pricing] ``` And on the CDN response headers you should see (when batch support is live): ``` X-Batch-Count: 4 X-Batch-Requested: 4 X-Cache-Status: HIT | MISS ``` ## Production Observations From the better-i18n.com landing page in production: | Page | Namespaces | First-visit CDN requests | Cached-visit requests | | --- | --- | --- | --- | | Home | 6 | 2 (manifest + batch) | 0 | | Pricing | 4 | 1 batch | 0 | | Blog post (cold cache) | 4 | 1 batch | 0 | | Any second page after home | 3-4 | 0-1 | 0 | After visiting 3-4 pages, most subsequent navigations produce **zero** CDN requests because shared namespaces (`common`, `navigation`, `footer`) are already in the SDK's per-namespace cache. ## Why the Per-Request Map? A single module-scoped `let ssrMessages: Messages` would race across concurrent requests on CF Workers — one request's render could read another request's messages. The `Map` pattern is race-free and standard for SSR side-channels on shared-isolate edge runtimes. ## Related - [Core — Selective Loading](/core/selective-loading) — feature overview - [TanStack Start — SSR](/frameworks/tanstack-start/ssr) — general SSR patterns - [TanStack Start — Middleware](/frameworks/tanstack-start/middleware) — locale detection and redirects --- # Path-Based Routing Source: https://help.better-i18n.com/hi/docs/frameworks/tanstack-start/routing This guide covers setting up SEO-friendly URLs like `/en/about` or `/tr/about` with TanStack Router. ## URL Strategy The default locale has **no prefix** (like next-intl): | Locale | URL | |--------|-----| | English (default) | `/about` | | Turkish | `/tr/about` | | German | `/de/about` | This provides: - Clean URLs for the primary language - SEO-friendly URLs for all locales - Easy sharing of localized links ## Setup ### Step 1: Create Locale Route Segment Create a `$locale` folder in your routes directory: ``` app/routes/ ├── __root.tsx ├── index.tsx # Redirects to /$locale └── $locale/ ├── index.tsx # /$locale home ├── about.tsx # /$locale/about └── contact.tsx # /$locale/contact ``` ### Step 2: Root Index Redirect Detect the locale and redirect — preloading messages into the cache to avoid a flash after redirect: ```tsx title="app/routes/__root.tsx" import { createFileRoute, redirect } from "@tanstack/react-router" import { getRequest } from "@tanstack/react-start/server" import { getMessages, detectLocale } from "@better-i18n/use-intl/server" // [!code highlight] import { i18nConfig } from "../i18n.config" export const Route = createRootRouteWithContext()({ staleTime: 0, // re-run loader on locale switch // [!code highlight] beforeLoad: async ({ location }) => { const locales = await fetchLocales() const firstSegment = location.pathname.split("/").filter(Boolean)[0] if (firstSegment && !locales.includes(firstSegment)) { const request = typeof window === "undefined" ? getRequest() : null const detectedLocale = detectLocale({ // [!code highlight] request, // [!code highlight] availableLocales: locales, // [!code highlight] defaultLocale: i18nConfig.defaultLocale, // [!code highlight] }) // [!code highlight] // Cache warm-up: preload before redirect so the loader hits TtlCache instantly await getMessages({ projectId: i18nConfig.projectId, locale: detectedLocale }).catch(() => {}) // [!code highlight] throw redirect({ href: `/${detectedLocale}${location.pathname}`, statusCode: 301, }) } }, }) ``` > [!NOTE] > `getMessages` is called before the redirect to warm up TtlCache. When the redirect lands and the loader runs, it hits the same in-memory cache — no second network round-trip, no white flash. ### Step 3: Update Root Layout Extract locale from the URL path: ```tsx title="app/routes/__root.tsx" import { getMessages } from "@better-i18n/use-intl/server" // [!code ++] import { i18nConfig } from "../i18n.config" export const Route = createRootRouteWithContext<{ locale: string }>()({ staleTime: 0, // re-run loader on locale switch // [!code highlight] loader: async ({ context, location }) => { // Extract locale from path: /tr/about → "tr" const pathParts = location.pathname.split("/") const pathLocale = pathParts[1] // [!code ++] // Validate it's a 2-letter locale code const locale = pathLocale && pathLocale.length === 2 // [!code ++] ? pathLocale // [!code ++] : context.locale || i18nConfig.defaultLocale // [!code ++] const messages = await getMessages({ projectId: i18nConfig.projectId, locale, }) return { messages, locale } }, component: RootComponent, }) ``` ### Step 4: Locale Pages Create pages inside the `$locale` folder: ```tsx title="app/routes/$locale/index.tsx" import { createFileRoute } from "@tanstack/react-router" import { useTranslations } from "@better-i18n/use-intl" export const Route = createFileRoute("/$locale/")({ component: HomePage, }) function HomePage() { const t = useTranslations("home") return (

{t("title")}

{t("description")}

) } ``` ## Language Switcher with Router Use `useLocaleRouter` for router-integrated switching: ```tsx import { useLocaleRouter, useLanguages } from "@better-i18n/use-intl" function LanguageSwitcher() { const { locale, navigate, isReady } = useLocaleRouter() const { languages } = useLanguages() if (!isReady) { return
} return (
{languages.map((lang) => ( ))}
) } ``` ### Why useLocaleRouter? Unlike `useLocale().setLocale()`, `useLocaleRouter().navigate()`: - ✅ Triggers proper SPA navigation - ✅ Re-executes loaders (fresh messages) - ✅ Updates URL correctly - ✅ Works with browser history ```tsx // ❌ State-only change (loaders don't re-run) const { setLocale } = useLocale() setLocale('tr') // ✅ Router navigation (loaders re-run) const { navigate } = useLocaleRouter() navigate('tr') ``` ## Generating Locale Paths Use `localePath` to generate localized links: ```tsx import { useLocaleRouter } from "@better-i18n/use-intl" import { Link } from "@tanstack/react-router" function Navigation() { const { localePath } = useLocaleRouter() return ( ) } ``` ### Cross-Locale Links Link to a specific locale: ```tsx function Footer() { const { localePath } = useLocaleRouter() return (

Also available in:

Türkçe Deutsch
) } ``` ## SEO Considerations ### Alternate Links Add `hreflang` tags for search engines: ```tsx title="app/routes/__root.tsx" export const Route = createRootRouteWithContext<{ locale: string }>()({ head: ({ loaderData }) => { const { locale } = loaderData const locales = ["en", "tr", "de"] const baseUrl = "https://example.com" const path = location.pathname.replace(`/${locale}`, "") return { links: [ // Canonical { rel: "canonical", href: `${baseUrl}${location.pathname}` }, // Alternates ...locales.map((loc) => ({ rel: "alternate", hreflang: loc, href: loc === "en" ? `${baseUrl}${path}` : `${baseUrl}/${loc}${path}`, })), // x-default { rel: "alternate", hreflang: "x-default", href: `${baseUrl}${path}` }, ], } }, }) ``` ### Sitemap Generate a sitemap with all locale variants: ```ts title="scripts/generate-sitemap.ts" const locales = ["en", "tr", "de"] const pages = ["/", "/about", "/contact", "/pricing"] const urls = pages.flatMap((page) => locales.map((locale) => ({ url: locale === "en" ? page : `/${locale}${page}`, alternates: locales.map((alt) => ({ hreflang: alt, href: alt === "en" ? page : `/${alt}${page}`, })), })) ) ``` ## Default Locale Handling For the default locale (English), you may want to handle both: - `/about` (no prefix) - `/en/about` (with prefix) ```tsx title="app/routes/en/index.tsx" import { createFileRoute, redirect } from "@tanstack/react-router" // Redirect /en to / export const Route = createFileRoute("/en/")({ beforeLoad: () => { throw redirect({ to: "/" }) }, }) ``` ## Related - [useLocaleRouter](/frameworks/locale) - Hook reference - [Middleware](/frameworks/tanstack-start/middleware) - Locale detection - [URL Strategy](/core/how-it-works) - Default locale patterns --- # Middleware Source: https://help.better-i18n.com/hi/docs/frameworks/tanstack-start/middleware Configure automatic locale detection using TanStack Start middleware. ## Basic Setup Create a middleware using the built-in helper: ```ts title="app/middleware/i18n.ts" import { createBetterI18nMiddleware } from "@better-i18n/use-intl/middleware" // [!code highlight] import { i18nConfig } from "../i18n.config" export const i18nMiddleware = createBetterI18nMiddleware({ // [!code highlight] projectId: i18nConfig.projectId, // [!code highlight] defaultLocale: i18nConfig.defaultLocale, // [!code highlight] }) // [!code highlight] ``` ## Register Middleware Add the middleware to your TanStack Start configuration: ```ts title="app/ssr.ts" import { createStart } from "@tanstack/react-start/server" import { i18nMiddleware } from "./middleware/i18n" // [!code ++] export default createStart({ middleware: [i18nMiddleware], // [!code ++] }) ``` ## Configuration Options ```ts export const i18nMiddleware = createBetterI18nMiddleware({ // Required projectId: "org/project", defaultLocale: "en", // Optional detection settings detection: { // Use Accept-Language header browserLanguage: true, // Use locale cookie cookie: true, // Cookie name (default: "locale") cookieName: "locale", // Cookie max age in seconds (default: 1 year) cookieMaxAge: 60 * 60 * 24 * 365, }, }) ``` ## Detection Order The middleware detects locale in this order: 1. **URL Path** - `/tr/about` → `tr` 2. **Cookie** - `locale=tr` → `tr` 3. **Accept-Language Header** - `tr-TR,tr;q=0.9` → `tr` 4. **Default** - Falls back to `defaultLocale` ## How It Works ``` Request: GET /about Headers: Accept-Language: tr-TR,tr;q=0.9,en;q=0.8 1. Check URL path → No locale prefix 2. Check cookie → No cookie 3. Check Accept-Language → "tr" detected 4. Set context.locale = "tr" 5. Set-Cookie: locale=tr ``` ## Cookie Persistence The middleware sets a cookie to persist the user's preference: ```ts // First visit (Accept-Language: tr) // → Cookie set: locale=tr; path=/; max-age=31536000 // Second visit (cookie exists) // → Locale from cookie, no header check ``` ## Disable Cookie To disable cookie-based persistence: ```ts export const i18nMiddleware = createBetterI18nMiddleware({ projectId: "org/project", defaultLocale: "en", detection: { cookie: false, browserLanguage: true, }, }) ``` ## Disable Browser Detection To ignore Accept-Language header: ```ts export const i18nMiddleware = createBetterI18nMiddleware({ projectId: "org/project", defaultLocale: "en", detection: { cookie: true, browserLanguage: false, }, }) ``` ## Custom Middleware For advanced use cases, use `detectLocale` from `@better-i18n/use-intl/server` to handle locale detection: ```ts title="app/middleware/i18n.ts" import { createMiddleware, getRequest } from "@tanstack/react-start/server" import { detectLocale } from "@better-i18n/use-intl/server" // [!code highlight] export const i18nMiddleware = createMiddleware().server(async ({ next }) => { const request = getRequest() const locale = detectLocale({ // [!code highlight] request, // [!code highlight] availableLocales: ["en", "tr", "de"], // [!code highlight] defaultLocale: "en", // [!code highlight] }) // [!code highlight] return next({ context: { locale } }) }) ``` `detectLocale` handles Accept-Language header parsing, quality factor (q-value) sorting, and best-match selection — no boilerplate needed. ### detectLocale Parameters | Parameter | Description | |---|---| | `request` | Server `Request` object — `Accept-Language` header is read from here | | `availableLocales` | List of supported locales to match against | | `defaultLocale` | Fallback locale when no match is found | > [!NOTE] > Server-side, `request` is used to read the `Accept-Language` header. On client-side navigation, `navigator.languages` is used automatically — the same `detectLocale` function works in both environments. ### Advanced: Manual Parsing For custom logic, you can use the lower-level utilities directly: ```ts import { parseAcceptLanguage, matchLocale } from "@better-i18n/use-intl/server" const languages = parseAcceptLanguage(request.headers.get("accept-language") ?? "") // → [{ locale: "tr", quality: 1 }, { locale: "en", quality: 0.8 }] const locale = matchLocale(languages, ["en", "tr", "de"], "en") // → "tr" ``` ## Accessing Locale in Routes The middleware sets `context.locale` which is accessible in all routes: ```tsx title="app/routes/__root.tsx" export const Route = createRootRouteWithContext<{ locale: string }>()({ loader: async ({ context }) => { console.log("Detected locale:", context.locale) const messages = await getMessages({ projectId: "org/project", locale: context.locale, }) return { messages, locale: context.locale } }, }) ``` ## Debugging Enable logging to debug locale detection: ```ts export const i18nMiddleware = createMiddleware().server(async ({ next, request }) => { const pathLocale = new URL(request.url).pathname.split("/")[1] const cookieLocale = getCookie(request, "locale") const browserLocale = request.headers.get("accept-language") console.log("Locale detection:", { path: pathLocale, cookie: cookieLocale, browser: browserLocale, url: request.url, }) // ... rest of middleware }) ``` ## Common Issues ### Locale Not Persisting **Symptom:** User's language preference resets on each visit. **Solution:** Ensure cookie is set correctly: ```ts detection: { cookie: true, cookieMaxAge: 60 * 60 * 24 * 365, // 1 year } ``` ### Wrong Locale Detected **Symptom:** Browser in Turkish but app shows English. **Solution:** Check detection order and cookie: ```ts // Debug by logging console.log({ cookie: request.headers.get("cookie"), acceptLanguage: request.headers.get("accept-language"), }) ``` ## Related - [Setup](/frameworks/tanstack-start/setup) - Basic configuration - [Routing](/frameworks/tanstack-start/routing) - Path-based locales - [SSR](/frameworks/tanstack-start/ssr) - Server rendering --- # TypeScript Source: https://help.better-i18n.com/hi/docs/frameworks/tanstack-start/typescript Enable autocomplete and type safety for your translation keys in TanStack Start applications. ## Setup ### Step 1: Create Type Declaration Create a type declaration file that references your translation messages: ```ts title="app/types/i18n.d.ts" import messages from '../locales/en.json' type Messages = typeof messages // [!code highlight] declare global { // [!code highlight] interface IntlMessages extends Messages {} // [!code highlight] } // [!code highlight] ``` > [!NOTE] > The `IntlMessages` interface is used by `use-intl` to provide type safety for translation keys. ### Step 2: Add Local Translation File For TypeScript to infer types, you need a local copy of your translations: ```json title="app/locales/en.json" { "home": { "title": "Welcome to our app", "description": "Get started by editing app/routes/index.tsx" }, "greeting": { "hello": "Hello, {name}!" } } ``` > [!WARNING] > This file is only for type inference. At runtime, translations are still fetched from the Better i18n CDN. ## Usage Once configured, you'll get autocomplete for translation keys: ```tsx import { useTranslations } from '@better-i18n/use-intl' function HomePage() { const t = useTranslations('home') return (
{/* ✅ TypeScript knows 'title' exists */}

{t('title')}

{/* ❌ TypeScript error: 'invalid' doesn't exist */}

{t('invalid')}

) } ``` ## TanStack Start-Specific Types ### RouterContext and getMessages The `getMessages` return type should match your `RouterContext` to ensure type safety across the SSR boundary: ```ts title="app/router.tsx" import type { Messages } from '@better-i18n/use-intl' interface RouterContext { locale: string messages?: Messages } ``` ```tsx title="app/routes/__root.tsx" import type { Messages } from '@better-i18n/use-intl' // Loader return type is inferred automatically export const Route = createRootRouteWithContext()({ loader: async ({ context }): Promise<{ messages: Messages; locale: string }> => { const locale = context.locale || 'en' const messages = await getMessages({ projectId: 'org/project', locale }) return { messages, locale } }, }) ``` ### createServerTranslator Types When using `createServerTranslator` for server-side translation, the `Messages` parameter is inferred from the `IntlMessages` global: ```ts title="app/server/translate.ts" import { createServerTranslator } from '@better-i18n/use-intl/server' // Messages parameter is typed as IntlMessages (your global declaration) const t = await createServerTranslator({ locale: 'tr', messages }) t('home.title') // ✅ type-safe ``` ## Namespaced Types For namespaced translations, your type file should reflect the structure: ```ts title="app/types/i18n.d.ts" import home from '../locales/en/home.json' import common from '../locales/en/common.json' type Messages = { home: typeof home common: typeof common } declare global { interface IntlMessages extends Messages {} } ``` ## Syncing Types ### CLI Sync (Recommended) Use the Better i18n CLI to keep types in sync: ```bash # Install CLI npm install -D @better-i18n/cli # Pull translations npx better-i18n pull --locale en --output app/locales ``` Add to your `package.json` scripts: ```json title="package.json" { "scripts": { "i18n:pull": "better-i18n pull --locale en --output app/locales" } } ``` ## Type Checking in CI Add type checking to your CI pipeline to catch missing translations: ```yaml title=".github/workflows/ci.yml" - name: Pull translations run: npm run i18n:pull - name: Type check run: npx tsc --noEmit ``` ## Next Steps - [Setup](/frameworks/tanstack-start/setup) — Return to the setup guide. - [SSR & Hydration](/frameworks/tanstack-start/ssr) — Configure server-side rendering properly. - [Hooks Reference](/frameworks/locale) — Explore all available hooks. --- # Vite Source: https://help.better-i18n.com/hi/docs/frameworks/vite ## Overview Better i18n for Vite works in two modes: - **With `@better-i18n/vite` plugin (recommended)** — Translations are fetched server-side and injected into HTML before React mounts. Zero FOUC, no client-side CDN requests, SEO-friendly. - **Without plugin (CSR fallback)** — Translations are fetched on the client after mount. Quick to set up, but shows a brief loading state on first render. > [!TIP] > Use the `@better-i18n/vite` plugin for production apps. It works like Next.js SSR — translations are embedded in the HTML, so the first render always has real content. > [!TIP] > **Integrating with AI?** Run `npx skills add better-i18n/skills` first — your agent (Cursor, Claude Code, or Windsurf) will already know the SDK patterns, CDN behavior, and key conventions. Then just ask it to set up the integration for you. [Learn more →](/mcp/agent-skill) ## Features - **Zero Config** — Point to your project and go - translations load automatically from the CDN. - **React Suspense** — Native Suspense support for elegant loading states. - **Type Safety** — Full TypeScript support with autocomplete for translation keys. - **Locale Storage** — Built-in localStorage persistence for user locale preference. ## Quick Start ### With Vite Plugin (Recommended) ```bash bun add @better-i18n/use-intl @better-i18n/vite use-intl ``` ```ts title="vite.config.ts" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { betterI18n } from '@better-i18n/vite' export default defineConfig({ plugins: [ betterI18n({ projectId: 'your-org/your-project' }), react(), ], }) ``` ```tsx title="src/App.tsx" import { BetterI18nProvider, LocaleDropdown } from '@better-i18n/use-intl' function App() { return ( ) } ``` No `project`, `locale`, or `messages` props needed — the plugin handles everything. ### Without Plugin (CSR) ```tsx title="src/App.tsx" import { BetterI18nProvider } from '@better-i18n/use-intl' function App() { return ( ) } ``` ```tsx title="src/components/Hello.tsx" import { useTranslations } from '@better-i18n/use-intl' export function Hello() { const t = useTranslations('home') return

{t('title')}

} ``` ## Guide - [Setup](/frameworks/vite/setup) — Installation, provider configuration, and loading states. - [React Router](/frameworks/vite/react-router) — URL-based locale routing with React Router. - [TypeScript](/frameworks/vite/typescript) — Type-safe translation keys and autocomplete. ## Comparison | Feature | Vite | Vite + Plugin | TanStack Start | Next.js | |---------|------|---------------|----------------|---------| | Rendering | CSR | SSR injection | CSR + SSR | CSR + SSR | | FOUC | Yes | **No** | No | No | | URL routing | Optional | Optional | Built-in | Built-in | | SEO | Limited | **Embedded** | Full | Full | | Client CDN requests | Yes | **No** (initial) | No | No | The `@better-i18n/vite` plugin brings SSR-like benefits to Vite apps without requiring a full SSR setup. ## AI Tooling Now that you've integrated the SDK, your AI agent can check coverage, translate keys, and publish — all without leaving your editor. - [MCP Server](/mcp/getting-started) — Translate keys, check coverage, and publish — all from your editor via Cursor, Claude Code, or Windsurf. - [Agent Skill](/mcp/agent-skill) — Permanent better-i18n knowledge for your AI agent — SDK patterns, CDN behavior, key conventions. No prompts needed each session. --- # Setup Source: https://help.better-i18n.com/hi/docs/frameworks/vite/setup This guide walks you through installing Better i18n and setting up the provider in your Vite + React application. ## Installation ```bash tab="npm" npm install @better-i18n/use-intl @better-i18n/vite use-intl ``` ```bash tab="yarn" yarn add @better-i18n/use-intl @better-i18n/vite use-intl ``` ```bash tab="pnpm" pnpm add @better-i18n/use-intl @better-i18n/vite use-intl ``` ```bash tab="bun" bun add @better-i18n/use-intl @better-i18n/vite use-intl ``` ## Configuration ### Step 1: Add the Vite Plugin Configure `@better-i18n/vite` in your Vite config. This is the **single source of truth** for your project — no need to repeat it in the provider. ```ts title="vite.config.ts" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { betterI18n } from '@better-i18n/vite' // [!code ++] export default defineConfig({ plugins: [ betterI18n({ projectId: 'your-org/your-project' }), // [!code ++] react(), ], }) ``` The plugin fetches translations **server-side** (in the Vite dev server or at build time) and injects them into your HTML as a ` ``` Or via `window.betterContent.track(...)` after `init()`. ## Property validation Properties must be primitive: `string`, `number`, `boolean`, or `null`. Nested objects and arrays are rejected: - **Development** (`NODE_ENV !== 'production'`): throws `Error("Invalid property...")` - **Production**: silently strips the invalid property and warns in debug mode This catches bugs early without breaking your app in prod. ```ts // ✅ OK track('content.view', { entryId: 'abc', count: 1, featured: true }) // ❌ Throws in dev, stripped in prod track('content.view', { entryId: 'abc', author: { id: '1' } }) ``` ## Why is my API key safe? | Concern | Mitigation | |---|---| | Key extracted from JS bundle | Key is write-only — can't read content or analytics | | Spam events from stolen key | Server-side rate limit (per IP, per project) + CF WAF | | Cross-project access | Key scoped to a single `projectId` at validation time | | Sensitive PII in properties | You control payload — never log emails, only IDs | Phase 1 mitigations: KV-cached key validation (1h TTL), CF Workers CPU/memory limits, server-side validation rejects malformed payloads. ## Proxy to avoid ad blockers Ad blockers may block requests to `content.better-i18n.com`. Proxy the track endpoint through your own domain so the browser only sees first-party requests. **Next.js rewrites** The simplest option for Next.js apps. Add a rewrite rule in `next.config.js`: ```js title="next.config.js" /** @type {import('next').NextConfig} */ const nextConfig = { async rewrites() { return [ { source: '/api/ca/:path*', destination: 'https://content.better-i18n.com/:path*', }, ] }, } export default nextConfig ``` Then point the SDK to your proxy: ```tsx ``` **Next.js middleware** If rewrites cause issues on your host, use a middleware (Next.js 16: `proxy.js`, 15 and earlier: `middleware.js`): ```ts title="middleware.ts" import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith('/api/ca/')) { const url = new URL( request.nextUrl.pathname.replace('/api/ca', ''), 'https://content.better-i18n.com', ) url.search = request.nextUrl.search return NextResponse.rewrite(url) } } export const config = { matcher: '/api/ca/:path*', } ``` **Cloudflare Worker** Zero egress fees and 100K free requests/day. Deploy a Worker on your domain: ```ts title="worker.ts" export default { async fetch(request: Request): Promise { const url = new URL(request.url) if (!url.pathname.startsWith('/api/ca/')) { return new Response('Not found', { status: 404 }) } const target = new URL( url.pathname.replace('/api/ca', ''), 'https://content.better-i18n.com', ) target.search = url.search const headers = new Headers(request.headers) headers.set('host', 'content.better-i18n.com') return fetch(target.toString(), { method: request.method, headers, body: request.body, }) }, } ``` Add a route in your `wrangler.toml` matching `/api/ca/*` on your domain. **Nginx / Caddy** ```nginx tab="Nginx:" location /api/ca/ { proxy_pass https://content.better-i18n.com/; proxy_set_header Host content.better-i18n.com; proxy_ssl_server_name on; } ``` ```txt tab="Caddy:" handle_path /api/ca/* { reverse_proxy https://content.better-i18n.com { header_up Host content.better-i18n.com } } ``` > [!NOTE] > Pick any path you like — `/api/ca/` is just a suggestion. Avoid obvious names like `/api/analytics` or `/api/track` that ad blockers might pattern-match. ## Next steps - [API Reference](/sdk/analytics-api) — full surface area: `track()`, `useTrackView()`, options - [Data Model](/sdk/analytics-data-model) — blob/double mapping, what's stored where --- # TypeScript Source: https://help.better-i18n.com/hi/docs/sdk/typescript The Content SDK is built with TypeScript and provides full type safety for all responses, including support for typed custom fields via generics. ## Generic Custom Fields Content models can define custom fields (e.g., `readingTime`, `category`). Custom fields are **spread flat onto the entry object** — there is no nested `customFields` wrapper. By default, additional fields are typed as `Record`. Use the generic type parameter on `.single()` to get exact typing: ```typescript // Define your custom fields interface interface BlogFields { readingTime: string | null; category: string | null; featured: string | null; } // Pass it to single() const { data: post } = await client .from("blog-posts") .single("hello-world"); post.readingTime; // string | null (typed!) post.category; // string | null (typed!) post.featured; // string | null (typed!) post.unknown; // TypeScript error! ``` > [!NOTE] > Custom fields are flat on the entry — access them as `post.readingTime`, not `post.customFields.readingTime`. ## Creating a Typed Client Wrapper For repeated use, create wrapper functions with your custom field types baked in: ```typescript import { createClient, type ContentEntry, type QueryResult, type ContentEntryListItem, type SingleQueryResult } from "@better-i18n/sdk"; interface BlogFields { readingTime: string | null; category: string | null; } const client = createClient({ projectId: "acme/web-app", apiKey: process.env.BETTER_I18N_API_KEY!, }); export async function getBlogPost(slug: string, language?: string): Promise>> { return client .from("blog-posts") .language(language ?? "en") .single(slug); } export async function listBlogPosts(page = 1): Promise[]>> { return client .from("blog-posts") .eq("status", "published") .order("publishedAt", { ascending: false }) .limit(10) .page(page); } ``` Usage stays clean and fully typed: ```typescript const { data: post } = await getBlogPost("hello-world", "en"); post?.readingTime; // string | null post?.category; // string | null const { data: posts, total } = await listBlogPosts(1); posts?.[0].readingTime; // string | null ``` ## Status Type Narrowing The `status` field is a union type, enabling exhaustive checks: ```typescript import type { ContentEntryStatus } from "@better-i18n/sdk"; function getStatusLabel(status: ContentEntryStatus): string { switch (status) { case "draft": return "Draft"; case "published": return "Published"; case "archived": return "Archived"; } } ``` ## Exported Types All types are re-exported from the package root: ```typescript import type { // Configuration ClientConfig, ContentClient, // Query builder classes ContentQueryBuilder, SingleQueryBuilder, // Content types ContentModel, ContentEntry, ContentEntryListItem, ContentEntryStatus, ContentEntrySortField, RelationValue, // Response types QueryResult, SingleQueryResult, } from "@better-i18n/sdk"; ``` --- # Next.js Source: https://help.better-i18n.com/hi/docs/sdk/nextjs The Next.js adapter wraps your app in a `ContentProvider` and exposes a `useTrackView` hook that's SSR-safe. It detects build-time and skips emission automatically. ## Install ```bash tab="npm" npm install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ## Configure Add your Project ID and API key to `.env.local`. Find both in the dashboard under **Settings → General** and **Settings → API Keys**. ```bash title=".env.local" NEXT_PUBLIC_BETTER_I18N_PROJECT_ID=your-org/your-project NEXT_PUBLIC_BETTER_I18N_KEY=bi_pub_xxxxx ``` ## Setup Wrap your app with `ContentProvider` in a client component: ```tsx title="app/providers.tsx" 'use client' import { ContentProvider } from '@better-i18n/content/adapters/nextjs' export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` Mount it in your root layout: ```tsx title="app/layout.tsx" import { Providers } from './providers' export default function RootLayout({ children }) { return ( {children} ) } ``` ## Track a view ```tsx title="app/blog/[slug]/page.tsx" 'use client' import { useTrackView } from '@better-i18n/content/adapters/nextjs' export default function BlogPost({ post }: { post: Post }) { useTrackView('content.view', { entryId: post.id, contentModel: 'blog', entrySlug: post.slug, language: post.locale, }) return
{post.body}
} ``` ## SSR safety `ContentProvider` creates the tracker synchronously, but `track()` is inherently SSR-safe — it checks `isBrowser()` internally and becomes a no-op on the server. `useTrackView` is also a no-op during SSR and during the Next.js build phase (`NEXT_PHASE=phase-production-build`). If `useTrackView` or `useContent` is used outside of a `ContentProvider`, the SDK logs a one-time warning and disables tracking — it never throws or crashes your app. ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # React Source: https://help.better-i18n.com/hi/docs/sdk/react The React adapter is framework-agnostic — use it with Vite, CRA, Remix client routes, or any React setup that isn't Next.js, Expo, or RSC. ## Install ```bash tab="npm" npm install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ## Configure Add your Project ID and API key to `.env`. Find both in the dashboard under **Settings → General** and **Settings → API Keys**. ```bash title=".env" VITE_BETTER_I18N_PROJECT_ID=your-org/your-project VITE_BETTER_I18N_KEY=bi_pub_xxxxx ``` ## Setup ```tsx title="src/main.tsx" import { ContentProvider } from '@better-i18n/content/adapters/react' import App from './App' createRoot(document.getElementById('root')!).render( ) ``` ## Track a view ```tsx title="src/routes/blog-post.tsx" import { useTrackView } from '@better-i18n/content/adapters/react' export function BlogPost({ post }: { post: Post }) { useTrackView('content.view', { entryId: post.id, contentModel: 'blog', entrySlug: post.slug, language: post.locale, }) return
{post.body}
} ``` ## Imperative tracking For non-view events (clicks, conversions), use the `useTrack` hook: ```tsx import { useTrack } from '@better-i18n/content/adapters/react' function CtaButton({ entryId }: { entryId: string }) { const track = useTrack() return ( ) } ``` ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # Expo Source: https://help.better-i18n.com/hi/docs/sdk/expo The Expo adapter wraps the React Native API and flushes pending events when the app goes to background. It uses `fetch` (React Native has no `sendBeacon`). ## Install ```bash tab="npm" npx expo install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ## Configure Add your Project ID and API key to `app.config.ts` (or `app.json`). Find both in the dashboard under **Settings → General** and **Settings → API Keys**. ```ts title="app.config.ts" export default { extra: { betterI18nProjectId: 'your-org/your-project', betterI18nKey: process.env.BETTER_I18N_KEY, }, } ``` ## Setup ```tsx title="App.tsx" import { ContentProvider } from '@better-i18n/content/adapters/expo' import Constants from 'expo-constants' export default function App() { return ( ) } ``` ## Track a view ```tsx title="screens/BlogPost.tsx" import { useTrackView } from '@better-i18n/content/adapters/expo' export function BlogPostScreen({ post }) { useTrackView('content.view', { entryId: post.id, contentModel: 'blog', entrySlug: post.slug, language: post.locale, }) return ... } ``` ## AppState flushing When the app transitions to `background` or `inactive`, the SDK flushes any pending events via `AppState.addEventListener`. You don't need to wire this up — `ContentProvider` handles it. ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # Svelte Source: https://help.better-i18n.com/hi/docs/sdk/svelte The Svelte adapter is initialized once at app boot and exposes a `track()` function plus an auto-view tracker for routes. ## Install ```bash tab="npm" npm install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ## Configure Add your Project ID and API key to `.env`. Find both in the dashboard under **Settings → General** and **Settings → API Keys**. ```bash title=".env" PUBLIC_BETTER_I18N_PROJECT_ID=your-org/your-project PUBLIC_BETTER_I18N_KEY=bi_pub_xxxxx ``` ## Setup ```ts title="src/routes/+layout.ts" import { initContent } from '@better-i18n/content/adapters/svelte' import { PUBLIC_BETTER_I18N_PROJECT_ID, PUBLIC_BETTER_I18N_KEY } from '$env/static/public' initContent({ projectId: PUBLIC_BETTER_I18N_PROJECT_ID, apiKey: PUBLIC_BETTER_I18N_KEY, }) ``` ## Track a view ```svelte title="src/routes/blog/[slug]/+page.svelte"
{@html data.post.body}
``` ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # Vue Source: https://help.better-i18n.com/hi/docs/sdk/vue The Vue adapter uses `provide`/`inject` to share the tracker across your component tree. ## Install ```bash tab="npm" npm install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ## Configure Add your Project ID and API key to `.env`. Find both in the dashboard under **Settings → General** and **Settings → API Keys**. ```bash title=".env" VITE_BETTER_I18N_PROJECT_ID=your-org/your-project VITE_BETTER_I18N_KEY=bi_pub_xxxxx ``` ## Setup (Vue 3) ```ts title="src/main.ts" import { createApp } from 'vue' import { provideContent } from '@better-i18n/content/adapters/vue' import App from './App.vue' const app = createApp(App) app.provide('content', provideContent({ projectId: import.meta.env.VITE_BETTER_I18N_PROJECT_ID, apiKey: import.meta.env.VITE_BETTER_I18N_KEY, })) app.mount('#app') ``` ## Setup (Nuxt) Create a plugin in `plugins/content.client.ts` so it only runs in the browser: ```ts title="plugins/content.client.ts" import { provideContent } from '@better-i18n/content/adapters/vue' export default defineNuxtPlugin((nuxtApp) => { nuxtApp.provide('content', provideContent({ projectId: useRuntimeConfig().public.betterI18nProjectId, apiKey: useRuntimeConfig().public.betterI18nKey, })) }) ``` ## Track a view ```vue title="pages/blog/[slug].vue" ``` ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # Vanilla JS Source: https://help.better-i18n.com/hi/docs/sdk/vanilla The vanilla adapter is a tiny, framework-free entrypoint. Drop it into any HTML page, static site, or non-React framework. ## CDN (no install) ```html ``` After `init()`, you can also call `window.betterContent.track(...)` from anywhere on the page. ## npm (bundled) ```bash tab="npm" npm install @better-i18n/content ``` ```bash tab="bun" bun add @better-i18n/content ``` ```bash tab="pnpm" pnpm add @better-i18n/content ``` ```bash tab="yarn" yarn add @better-i18n/content ``` ```ts title="src/analytics.ts" import { init, track } from '@better-i18n/content/adapters/vanilla' init({ projectId: process.env.BETTER_I18N_PROJECT_ID!, apiKey: process.env.BETTER_I18N_KEY!, }) // Call track() from anywhere track('content.view', { entryId: post.id, language: 'en', }) ``` ## Astro In Astro, the adapter goes in a client-side ` ``` ## Next steps - [Analytics API Reference](/sdk/analytics-api) - [Analytics Overview](/sdk/analytics) --- # Content SDK API Reference Source: https://help.better-i18n.com/hi/docs/sdk/api-reference ## `createClient(config)` Creates a content client for fetching models and entries. ```typescript import { createClient } from "@better-i18n/sdk"; const client = createClient({ projectId: "acme/web-app", apiKey: "bi-your-api-key", }); ``` **Parameters:** | Field | Type | Required | Description | | --- | --- | --- | --- | | `projectId` | `string` | Yes | Project ID — `org/project` slug or canonical UUID | | `apiKey` | `string` | Yes | API key (prefix: `bi-`) | | `apiBase` | `string` | No | API URL. Default: `https://content.better-i18n.com` | | `debug` | `boolean` | No | Log request URLs and responses to console | **Returns:** `ContentClient` --- ## `client.from(modelSlug)` Start a chainable query builder for a content model. This is the primary API for fetching entries. ```typescript const builder = client.from("blog-posts"); // [!code highlight] ``` **Parameters:** | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `modelSlug` | `string` | Yes | Content model slug | **Returns:** `ContentQueryBuilder` The builder is **immutable** — every chained method returns a new builder instance. The builder is **thenable**: `await` it directly to execute a list query. --- ## Query Builder Methods All methods return a new `ContentQueryBuilder` instance and can be chained in any order. | Method | Description | | --- | --- | | `.select(...fields)` | Choose which fields to include in the response | | `.eq(field, value)` | Filter by field value (`"status"` or any custom field) | | `.filter(field, value)` | Filter by custom field value | | `.search(term)` | Full-text search on entry titles | | `.language(code)` | Set language code for localized content | | `.order(field, opts?)` | Set sort field and direction | | `.limit(n)` | Limit results per page (1–100) | | `.page(n)` | Set page number (1-based) | | `.expand(...fields)` | Expand relation fields inline | ### `.select(...fields)` Choose which fields to include. `slug` and `publishedAt` are always returned. When omitted, all fields are returned. ```typescript const { data } = await client .from("blog-posts") .select("title", "body", "category"); ``` | Parameter | Type | Description | | --- | --- | --- | | `...fields` | `string[]` | Field names to include | ### `.eq(field, value)` Filter entries by exact field value. Works for the built-in `status` field and any custom field. ```typescript const { data } = await client .from("blog-posts") .eq("status", "published"); ``` | Parameter | Type | Description | | --- | --- | --- | | `field` | `string` | Field name (`"status"` or custom field name) | | `value` | `string` | Value to match | ### `.filter(field, value)` Filter by a custom field value. Equivalent to `.eq()` for custom fields. ```typescript const { data } = await client .from("blog-posts") .filter("category", "engineering"); ``` | Parameter | Type | Description | | --- | --- | --- | | `field` | `string` | Custom field name | | `value` | `string` | Value to match | ### `.search(term)` Full-text search on entry titles. ```typescript const { data } = await client .from("blog-posts") .search("kubernetes"); ``` | Parameter | Type | Description | | --- | --- | --- | | `term` | `string` | Search term | ### `.language(code)` Set the language code for localized content. Falls back to the source language when the requested language has no translation. ```typescript const { data } = await client .from("blog-posts") .language("fr") .single("hello-world"); ``` | Parameter | Type | Description | | --- | --- | --- | | `code` | `string` | BCP 47 language code (e.g. `"en"`, `"fr"`, `"tr"`) | ### `.order(field, options?)` Set the sort field and direction. ```typescript const { data } = await client .from("blog-posts") .order("publishedAt", { ascending: false }); ``` | Parameter | Type | Description | | --- | --- | --- | | `field` | `ContentEntrySortField` | `"publishedAt"`, `"createdAt"`, `"updatedAt"`, or `"title"` | | `options.ascending` | `boolean` | `true` for ascending, `false` for descending. Default: `false` | ### `.limit(n)` Limit the number of entries returned per page. ```typescript const { data } = await client.from("blog-posts").limit(20); ``` | Parameter | Type | Description | | --- | --- | --- | | `n` | `number` | Entries per page (1–100). Default: `50` | ### `.page(n)` Set the page number for pagination (1-based). ```typescript const { data } = await client.from("blog-posts").limit(20).page(3); ``` | Parameter | Type | Description | | --- | --- | --- | | `n` | `number` | Page number starting at `1`. Default: `1` | ### `.expand(...fields)` Expand relation fields, resolving referenced entries inline. Expanded relations appear in a `relations` key on each entry. The relation object's custom fields are flat on the relation object itself. ```typescript const { data } = await client .from("blog-posts") .expand("author", "category"); ``` | Parameter | Type | Description | | --- | --- | --- | | `...fields` | `string[]` | Relation field names to expand | --- ## Terminal Methods ### `.single(slug)` Fetch a single entry by slug. Returns a `SingleQueryBuilder` which is thenable — `await` it directly. ```typescript const { data: post, error } = await client // [!code highlight] .from("blog-posts") // [!code highlight] .language("en") // [!code highlight] .single("hello-world"); // [!code highlight] ``` | Parameter | Type | Description | | --- | --- | --- | | `slug` | `string` | Entry slug | **Type Parameter:** `CF extends Record` — Custom fields shape. Defaults to `Record`. See [TypeScript guide](/sdk/typescript). **Returns:** `SingleQueryBuilder` (thenable → `SingleQueryResult>`) ### `await builder` (list query) Awaiting a `ContentQueryBuilder` directly executes a list query. ```typescript const { data, error, total, hasMore } = await client .from("blog-posts") .eq("status", "published"); ``` **Returns:** `QueryResult` --- ## Return Types ### `QueryResult` Returned when awaiting a list query (`await client.from(...)`). | Field | Type | Description | | --- | --- | --- | | `data` | `T \| null` | Query results, or `null` on error | | `error` | `Error \| null` | Error if the request failed, otherwise `null` | | `total` | `number` | Total matching entries across all pages | | `hasMore` | `boolean` | Whether more pages exist beyond the current page | ### `SingleQueryResult` Returned when awaiting a single query (`await client.from(...).single(...)`). | Field | Type | Description | | --- | --- | --- | | `data` | `T \| null` | Entry data, or `null` if not found or on error | | `error` | `Error \| null` | Error if the request failed, otherwise `null` | --- ## `client.getModels()` List all content models in the project. ```typescript const models = await client.getModels(); ``` **Returns:** `Promise` | Field | Type | Description | | --- | --- | --- | | `slug` | `string` | URL-safe identifier | | `displayName` | `string` | Human-readable name | | `description` | `string \| null` | Model description | | `kind` | `string` | `"collection"` or `"single"` | | `entryCount` | `number` | Number of entries | --- ## Legacy Methods > [!WARNING] > These methods are deprecated. Use `from()` for all new code. They will be removed in a future major version. ### `client.getEntries(modelSlug, options?)` — deprecated Use `client.from(modelSlug)` instead. ```typescript // Deprecated const { items, total, hasMore } = await client.getEntries("blog-posts", { status: "published", }); // Preferred const { data, total, hasMore } = await client .from("blog-posts") .eq("status", "published"); ``` ### `client.getEntry(modelSlug, entrySlug, options?)` — deprecated Use `client.from(modelSlug).single(entrySlug)` instead. ```typescript // Deprecated const post = await client.getEntry("blog-posts", "hello-world", { language: "fr", }); // Preferred const { data: post } = await client .from("blog-posts") .language("fr") .single("hello-world"); ``` --- ## REST API Endpoints The SDK calls these REST endpoints under the hood: | Method | Endpoint | SDK | | --- | --- | --- | | `GET` | `/v1/content/{org}/{project}/models` | `getModels()` | | `GET` | `/v1/content/{org}/{project}/models/{model}/entries` | `from(model)` | | `GET` | `/v1/content/{org}/{project}/models/{model}/entries/{slug}` | `from(model).single(slug)` | All requests use the `x-api-key` header for authentication. --- ## Types "> Returned by `.single()`. Custom fields are spread directly onto the entry via `& CF` — there is no nested `customFields` wrapper. Base fields (always included): | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique entry ID | | `slug` | `string` | URL-safe identifier | | `status` | `ContentEntryStatus` | Entry status | | `publishedAt` | `string \| null` | ISO 8601 publish date | | `sourceLanguage` | `string` | Project's source language code | | `availableLanguages` | `string[]` | Language codes with translations | | `title` | `string` | Localized title | | `body` | `string \| null` | Localized body as Markdown | | `relations` | `Record` | Expanded relations (only when `expand` is used) | Additional fields (present on single-entry responses): | Field | Type | Description | | --- | --- | --- | | `availableLanguageDetails` | `ContentEntryLanguage[] \| undefined` | Rich language descriptors with display name and country code — useful for language pickers | | `translationStatus` | `Record \| undefined` | Per-language translation publish status | | `bodyHtml` | `string \| undefined` | Body rendered as an HTML string | | `bodyMarkdown` | `string \| undefined` | Body as a plain Markdown string (alias for `body`) | Custom fields from `CF` are spread flat onto this object. "> Returned in list query results. Custom fields are spread directly onto the item via `& CF` — there is no nested `customFields` wrapper. | Field | Type | Description | | --- | --- | --- | | `slug` | `string` | URL-safe identifier (always included) | | `publishedAt` | `string \| null` | ISO 8601 publish date (always included) | | `title` | `string` | Entry title | | `body` | `string \| null` | Markdown body (only when requested via `.select()`) | | `relations` | `Record` | Expanded relations (only when `expand` is used) | Custom fields from `CF` are spread flat onto this object. | Field | Type | Description | | --- | --- | --- | | `slug` | `string` | URL-safe identifier | | `displayName` | `string` | Human-readable name | | `description` | `string \| null` | Model description | | `kind` | `"collection" \| "single"` | Model kind | | `entryCount` | `number` | Number of entries | | `includeBody` | `boolean` | Whether the model has a rich-text body field | | `fields` | `ContentModelField[]` | Custom field definitions | Field definition returned inside `ContentModel.fields`. | Field | Type | Description | | --- | --- | --- | | `name` | `string` | Field identifier (snake_case) | | `displayName` | `string` | Human-readable field label | | `type` | `string` | Field type (`text`, `textarea`, `richtext`, `number`, `boolean`, `date`, `datetime`, `enum`, `media`, `relation`) | | `required` | `boolean` | Whether the field is required | | `localized` | `boolean` | Whether the field is translated per language | | `enumValues` | `ContentModelEnumValue[] \| undefined` | Enum options — only present when `type` is `"enum"` | | `fieldConfig.targetModel` | `string \| undefined` | Target model slug — only present when `type` is `"relation"` | "> | Field | Type | Description | | --- | --- | --- | | `data` | `T \| null` | Query results | | `error` | `Error \| null` | Error if request failed | | `total` | `number` | Total matching entries | | `hasMore` | `boolean` | Whether more pages exist | "> | Field | Type | Description | | --- | --- | --- | | `data` | `T \| null` | Entry data | | `error` | `Error \| null` | Error if request failed | Returned for each key in `relations` when `.expand()` is used. Custom fields of the referenced entry are spread flat onto this object — no nested `customFields` wrapper. | Field | Type | Description | | --- | --- | --- | | `id` | `string` | Unique ID of the referenced entry | | `slug` | `string` | URL-safe slug of the referenced entry | | `title` | `string` | Display title of the referenced entry | | `modelSlug` | `string` | Content model the referenced entry belongs to | Additional `string | null` keys represent the referenced entry's custom fields. ```typescript type ContentEntryStatus = "draft" | "published" | "archived"; ``` ```typescript type ContentEntrySortField = "publishedAt" | "createdAt" | "updatedAt" | "title"; ``` --- # Analytics API Reference Source: https://help.better-i18n.com/hi/docs/sdk/analytics-api The full public API of `@better-i18n/content`. All exports are tree-shakeable (`sideEffects: false`). ## Imports The package uses subpath exports — one entry per adapter, plus core. ```ts // Framework-agnostic core (Node, Workers, Deno) import { createTracker } from '@better-i18n/content' // React (generic) import { ContentProvider, useTrack, useTrackView } from '@better-i18n/content/adapters/react' // Next.js (re-exports React adapter with 'use client') import { ContentProvider, useTrack, useTrackView } from '@better-i18n/content/adapters/nextjs' // Expo / React Native import { ContentProvider, useTrack, useTrackView } from '@better-i18n/content/adapters/expo' // Vue import { provideContent, useContent, useTrack } from '@better-i18n/content/adapters/vue' // Svelte import { initContent, track, reset } from '@better-i18n/content/adapters/svelte' // Vanilla / UMD import { init, track, reset } from '@better-i18n/content/adapters/vanilla' // Test utilities import { createFakeCollector, createMockConfig } from '@better-i18n/content/test' // Types only import type { ContentConfig, TrackOptions } from '@better-i18n/content/types' ``` ## `createTracker(config)` Factory that creates a tracker instance. Runtime-agnostic — works in browsers, Node.js, Cloudflare Workers, Deno. ```ts function createTracker(config: ContentConfig): { track: (eventName: string, properties?: EventProperties, options?: TrackOptions) => void reset: () => void } ``` ### `ContentConfig` | Property | Type | Required | Description | |---|---|---|---| | `projectId` | `string` | ✅ | Your Better i18n project ID | | `apiKey` | `string` | ✅ | Public key (`bi_pub_*`) | | `analytics.enabled` | `boolean` | | Default `true`. Set `false` to no-op all calls. | | `analytics.endpoint` | `string` | | Override the ingestion URL. Default `https://content.better-i18n.com/v1/track`. | | `analytics.debug` | `boolean` | | Logs transport failures to console. | ## `track(eventName, properties?, options?)` Send a single event. ```ts track( eventName: string, properties?: Record, options?: TrackOptions, ): void ``` ### `TrackOptions` | Property | Type | Description | |---|---|---| | `identity.userId` | `string` | Stable user ID (hashed before storage) | | `identity.email` | `string` | User email (not stored, used only for routing) | | `identity.anonymousId` | `string` | Pre-login session ID | | `allowServer` | `boolean` | Allow calling from server runtime (Node/Workers). Default `false`. | ### Reserved property names These property keys are extracted and mapped to specific AE columns: | Property | Description | |---|---| | `entryId` | UUID of the content entry | | `contentModelSlug` | Slug of the model (e.g. `blog`, `faq`) | | `entrySlug` (or `slug`) | Human-readable entry slug | | `language` | Locale code (`en`, `tr`, ...) | | `framework` | SDK adapter (`nextjs`, `react`, `expo`, ...) — set automatically by adapter | | `sdkVersion` | Set automatically | | `hostname` | Defaults to `window.location.hostname` if not provided | | `path` | Defaults to `window.location.pathname` if not provided | | `referrer` | Defaults to `document.referrer` if not provided | | `environment` | `production` / `preview` / `development` | | `loadTimeMs` | Numeric — content render time | All other properties are accepted but stored as a JSON blob (Phase 2 — currently dropped). ## React hooks ### `useContent()` Returns the tracker context. If called outside ``, logs a one-time warning and returns a no-op context — your app never crashes, tracking is simply disabled. ```ts const { track, reset, config } = useContent() ``` ### `useTrack()` Shortcut for `useContent().track`. ```ts const track = useTrack() track('content.view', { entryId: 'abc' }) ``` ### `useTrackView(eventName?, properties?)` Fires a single event on mount. De-duped — won't refire on re-render. ```ts useTrackView('content.view', { entryId: post.id, contentModel: 'blog', }) ``` Default event name is `'content.view'`. Properties update fires a new event when shallow-changed. ## Vue API ### `provideContent(config)` Provides the tracker to all descendants. Call once in your root component. ```ts provideContent({ projectId, apiKey }) ``` ### `useTrack()` / `useContent()` Inject the tracker. If `provideContent()` wasn't called, logs a one-time warning and returns a no-op tracker. ```ts const track = useTrack() track('content.view', { entryId: 'abc' }) ``` ## Svelte API ### `initContent(config)` Initializes the global tracker. Call once at app load. ```ts initContent({ projectId, apiKey }) ``` ### `track()` / `reset()` Module-level functions. Warns if `initContent()` wasn't called. ## Vanilla API ```ts // One-time init init({ projectId, apiKey }) // Anywhere track('content.view', { entryId: 'abc' }) // Or via window global (after init): window.betterContent.track('content.view', { entryId: 'abc' }) ``` ## Test utilities For unit testing your code that uses the SDK: ```ts import { createFakeCollector, createMockConfig } from '@better-i18n/content/test' const collector = createFakeCollector() const config = createMockConfig({ projectId: 'test' }) // Inject collector.track instead of the real track function collector.track('content.view', { entryId: 'abc' }) expect(collector.getEvents()).toHaveLength(1) expect(collector.getLastEvent()?.eventName).toBe('content.view') ``` ## Type exports ```ts import type { ContentConfig, AnalyticsConfig, TrackOptions, IdentityOptions, EventProperties, TrackEvent, } from '@better-i18n/content/types' ``` ## Bundle size | Entry | Min + gzip | |---|---| | `@better-i18n/content` (core) | ~1.1 KB | | `/adapters/nextjs` | ~1.6 KB | | `/adapters/react` | ~1.6 KB | | `/adapters/expo` | ~2.0 KB | | `/adapters/vanilla` | ~0.9 KB | No runtime dependencies. Tree-shakeable. ## Next steps - [Analytics overview](/sdk/analytics) — concepts, transport, safety - [Data Model](/sdk/analytics-data-model) — AE schema, what's stored where --- # Data Model Source: https://help.better-i18n.com/hi/docs/sdk/analytics-data-model Content analytics events are written to **Cloudflare Analytics Engine** (`contentAnalytics` dataset). This page explains the schema so you understand what's stored, what's queryable, and what's not. ## Why CF Analytics Engine? | Feature | Benefit | |---|---| | Fire-and-forget writes | `writeDataPoint()` is non-blocking — ingestion never slows down responses | | Built-in sampling | High-volume events get statistically sampled (still accurate via `SUM(_sample_interval)`) | | 90-day retention | Free tier; we archive older data to R2 | | SQL via REST API | Same queries we use power your dashboard charts | | No schema migrations | Adding new dimensions = adding a new blob slot | The trade-off: AE is **append-only**. Events can't be edited or deleted. This is fine for analytics but means you can't "fix" a past event. ## Schema The `contentAnalytics` dataset uses 1 index, 14 blobs (strings), 2 doubles (numbers). ### Indexes | Slot | Field | Notes | |---|---|---| | `index1` | `orgId` | Partition key — max 32 bytes. Queries by `orgId` are fast partitioned scans. | ### Blobs (strings) | Slot | Field | Source | |---|---|---| | `blob1` | `projectId` | From API key validation | | `blob2` | `eventName` | e.g. `content.view`, `content.click` | | `blob3` | `entryId` | From `properties.entryId` | | `blob4` | `contentModelSlug` | From `properties.contentModelSlug` | | `blob5` | `entrySlug` | From `properties.entrySlug` (or `properties.slug`) | | `blob6` | `language` | From `properties.language` | | `blob7` | `framework` | From `properties.framework` (set by adapter) | | `blob8` | `countryCode` | From `request.cf.country` (edge metadata, not IP) | | `blob9` | `sdkVersion` | From `properties.sdkVersion` | | `blob10` | `hostname` | From `properties.hostname` or `window.location.hostname` | | `blob11` | `path` | From `properties.path` or `window.location.pathname` | | `blob12` | `referrer` | From `properties.referrer` or `document.referrer` | | `blob13` | `userId` | From `identity.userId` or `identity.anonymousId` | | `blob14` | `environment` | `production` / `preview` / `development` | ### Doubles (numbers) | Slot | Field | Notes | |---|---|---| | `double1` | `count` | Always `1`. Sum across rows for total event count (sampling-aware via `SUM(_sample_interval)`). | | `double2` | `loadTimeMs` | Numeric — content render time, if provided | ### What's NOT stored - **IP addresses** — only country code from CF edge metadata - **User agent** — not collected (Phase 1) - **Email addresses** — `identity.email` is used for routing but never persisted - **Arbitrary properties** — only the reserved property names map to AE columns. Phase 2 will support a free-form JSON blob. ## Query patterns All reads go through `POST /api/trpc/contentAnalytics.getContentStats`. Internally, six AE SQL queries run in parallel: ```sql -- Overview: total views + unique entries SELECT SUM(_sample_interval) AS total_views, COUNT(DISTINCT blob3) AS unique_entries FROM contentAnalytics WHERE index1 = '{orgId}' AND blob1 = '{projectId}' AND blob2 = 'content.view' AND timestamp > NOW() - INTERVAL '7' DAY ``` ```sql -- Top entries SELECT blob3 AS entry_id, blob5 AS entry_slug, blob4 AS model_slug, SUM(_sample_interval) AS views FROM contentAnalytics WHERE index1 = '{orgId}' AND blob1 = '{projectId}' AND blob2 = 'content.view' AND timestamp > NOW() - INTERVAL '7' DAY GROUP BY entry_id, entry_slug, model_slug ORDER BY views DESC LIMIT 20 ``` ```sql -- Time series — hourly for 24h, daily for 7d/30d SELECT toStartOfInterval(timestamp, INTERVAL '1' DAY) AS ts, SUM(_sample_interval) AS views FROM contentAnalytics WHERE index1 = '{orgId}' AND blob1 = '{projectId}' AND blob2 = 'content.view' AND timestamp > NOW() - INTERVAL '7' DAY GROUP BY ts ORDER BY ts ASC ``` Results are cached in KV: 5 min (24h period), 15 min (7d), 1 hour (30d). ## SQL gotchas If you query AE directly via the REST API, watch for: - **All numeric values come back as strings** — always `Number()` cast on the client - **No `COALESCE`** — empty time buckets must be filled in JS - **No `OFFSET`** — pagination is `LIMIT n` then `.slice()` in JS - **No parameterized queries** — escape interpolated values yourself (we whitelist `[a-zA-Z0-9-]` for IDs) - **`SUM(_sample_interval)` for counts**, not `COUNT(*)` — respects sampling ## Retention | Layer | Duration | |---|---| | Hot (queryable AE) | 90 days | | Cold (R2 archive) | Roadmap: indefinite, daily Apache Arrow exports | After 90 days, AE drops events. We'll mirror Counterscale's pattern: a daily cron exports the previous day's data to R2 as Apache Arrow IPC files for long-term analysis. ## Capacity & limits | Limit | Value | |---|---| | Max `writeDataPoint` calls per Worker invocation | 250 | | Max blob size per data point | 16 KB total | | Max blobs per data point | 20 (we use 14) | | Max doubles per data point | 20 (we use 2) | | Per-dataset write rate | No documented hard limit — CF auto-throttles at infrastructure level | Phase 2 mitigations on top: per-IP rate limit (~50 req/s burst), per-project quota tied to plan, datacenter IP and bot-UA drop. ## Next steps - [Analytics overview](/sdk/analytics) — concepts, transport, safety - [API Reference](/sdk/analytics-api) — full SDK surface --- # MCP Server Source: https://help.better-i18n.com/hi/docs/mcp Better i18n MCP (Model Context Protocol) is the bridge between your AI assistant and your translation workspace. It allows tools like ChatGPT, Claude, Cursor, and Gemini to directly manage your project's i18n lifecycle. ## Two MCP Servers Better i18n provides **two focused MCP servers** to keep tool lists manageable: | Package | Tools | Purpose | |---|---|---| | `@better-i18n/mcp` | 11 tools | Translation management (keys, translations, publishing) | | `@better-i18n/mcp-content` | 17 tools | Content management (models, entries, fields, localized content) | Install one or both depending on your workflow. ## Core Features - **AI-Native Workflow** — Empower your AI to fix missing translations and create content. - **Zero Scan Latency** — Uses your `i18n.config.ts` to immediately connect to the cloud. - **Efficient Bulk Updates** — Single commands to update hundreds of values in seconds. - **Context-Aware** — AI understands your namespaces, models, and project structure. ## Quick Start **AI Assistants** Connect **ChatGPT**, **Claude**, or **Gemini** using the remote MCP server URL: ``` https://mcp.better-i18n.com/mcp ``` Paste this URL in your assistant's MCP settings and sign in with your Better i18n account. No installation needed. See the [AI Assistants guide](/mcp/ai-assistants) for step-by-step instructions. **Coding Agents** Add the server to **Cursor**, **Claude Desktop**, or **Windsurf**: ```bash npx -y @better-i18n/mcp@latest ``` See the [Setup Guide](/mcp/getting-started) for API key and environment configuration. ## Why use MCP? Instead of manually copying keys and text to the dashboard, you can simply ask: > "Translate the new auth namespace to Turkish and German." The AI assistant will: 1. Verify the keys exist. 2. Generate accurate translations in context. 3. Apply them directly to your project using the [Available Tools](/mcp/tool-reference). --- # Coding Agents Source: https://help.better-i18n.com/hi/docs/mcp/getting-started Connect coding agents like Cursor, Claude Code, Windsurf, Zed, Codex, and Antigravity to your translation workspace. > [!NOTE] > **Two ways to connect.** Agents that support **remote MCP** (Claude Code, Cursor) can use the hosted server at `https://mcp.better-i18n.com/mcp` and sign in via **OAuth — no API key needed**. Agents that only run local stdio servers use the `npx` bridge with an API key. Both are shown per-agent below. > [!NOTE] > For **ChatGPT**, **Claude** (web), or **Gemini**, use the [AI Assistants](/mcp/ai-assistants) guide instead — no installation required. ### Get your API Key 1. Sign in to [dash.better-i18n.com](https://dash.better-i18n.com). 2. Go to **Settings > API Keys**. 3. Create a new key and copy it. ### Configure Project Context The MCP server needs to know which project to manage. It reads the `projectId` field from your `i18n.config.ts`. ```ts title="i18n.config.ts" import { createI18n } from "@better-i18n/next"; export const i18n = createI18n({ projectId: "your-org/your-project", // MCP uses this ID // [!code highlight] defaultLocale: "en", }); ``` ### Configure your AI Assistant Pass your API key using the `BETTER_I18N_API_KEY` environment variable. **Cursor** **Recommended — OAuth, no API key.** Add the remote server in `Settings → MCP → Add new MCP server`, choose **Remote**, and paste: ``` https://mcp.better-i18n.com/mcp ``` Cursor opens your browser to sign in on first use — no key to manage. **Alternative — API key** (`~/.cursor/mcp.json`): ```json title="~/.cursor/mcp.json" { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Claude Code** **Recommended — OAuth, no API key.** Add the remote server and sign in through your browser: ```bash title="Terminal" claude mcp add --transport http --scope user better-i18n https://mcp.better-i18n.com/mcp ``` Then run `/mcp` inside Claude Code, pick **better-i18n → Authenticate**, and approve in your browser. No key to copy or rotate — the token refreshes automatically. **Alternative — API key** (for offline / self-hosted setups): ```bash title="Terminal" claude mcp add better-i18n -s user -e BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp@latest ``` Claude Code automatically manages the `.mcp.json` configuration. View and manage servers with `claude mcp list`. **Claude Desktop** Add this to your Claude Desktop config: ```json { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Windsurf** Add this to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`): ```json title="~/.codeium/windsurf/mcp_config.json" { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Zed** Add this to your Zed settings (`~/.config/zed/settings.json`): ```json title="~/.config/zed/settings.json" { "context_servers": { "better-i18n": { "command": { "path": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "settings": {} } } } ``` **Codex** **Option 1: CLI command** ```bash title="Terminal" codex mcp add better-i18n --env BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp@latest ``` **Option 2: Manual config** (`~/.codex/config.toml`) ```toml title="~/.codex/config.toml" [mcp_servers.better-i18n] command = "npx" args = ["-y", "@better-i18n/mcp@latest"] [mcp_servers.better-i18n.env] BETTER_I18N_API_KEY = "your-api-key" ``` **Antigravity** Add this to your `GEMINI.md` or `.rules` file: ```markdown title="GEMINI.md" MCP Servers: - better-i18n: @better-i18n/mcp@latest (stdio) Environment Variables: BETTER_I18N_API_KEY: your-api-key ``` ### Verify Connectivity Ask your AI assistant a translation-related question to verify the setup: > "Show me the translation status for my current project." If configured correctly, the AI will use the `getProject` tool to fetch your real-time stats. ## Content Management MCP If you also use Better i18n's headless CMS features, add the content MCP server separately: **Cursor** ```json title="~/.cursor/mcp.json" { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "better-i18n-content": { "command": "npx", "args": ["-y", "@better-i18n/mcp-content@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Claude Code** Run both commands in your terminal: ```bash title="Terminal" claude mcp add better-i18n -s user -e BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp@latest claude mcp add better-i18n-content -s user -e BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp-content@latest ``` **Claude Desktop** ```json { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "better-i18n-content": { "command": "npx", "args": ["-y", "@better-i18n/mcp-content@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Windsurf** ```json title="~/.codeium/windsurf/mcp_config.json" { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "better-i18n-content": { "command": "npx", "args": ["-y", "@better-i18n/mcp-content@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } } } } ``` **Zed** ```json title="~/.config/zed/settings.json" { "context_servers": { "better-i18n": { "command": { "path": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "settings": {} }, "better-i18n-content": { "command": { "path": "npx", "args": ["-y", "@better-i18n/mcp-content@latest"], "env": { "BETTER_I18N_API_KEY": "your-api-key" } }, "settings": {} } } } ``` **Codex** **Option 1: CLI commands** ```bash title="Terminal" codex mcp add better-i18n --env BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp@latest codex mcp add better-i18n-content --env BETTER_I18N_API_KEY=your-api-key -- npx -y @better-i18n/mcp-content@latest ``` **Option 2: Manual config** (`~/.codex/config.toml`) ```toml title="~/.codex/config.toml" [mcp_servers.better-i18n] command = "npx" args = ["-y", "@better-i18n/mcp@latest"] [mcp_servers.better-i18n.env] BETTER_I18N_API_KEY = "your-api-key" [mcp_servers.better-i18n-content] command = "npx" args = ["-y", "@better-i18n/mcp-content@latest"] [mcp_servers.better-i18n-content.env] BETTER_I18N_API_KEY = "your-api-key" ``` **Antigravity** ```markdown title="GEMINI.md" MCP Servers: - better-i18n: @better-i18n/mcp@latest (stdio) - better-i18n-content: @better-i18n/mcp-content@latest (stdio) Environment Variables: BETTER_I18N_API_KEY: your-api-key ``` > [!NOTE] > The translation and content MCP servers are separate packages to keep each focused. You can install one or both depending on your needs. ## Environment Variables | Variable | Required | Description | | --------------------- | -------- | ---------------------------------------------- | | `BETTER_I18N_API_KEY` | Yes | Your organization API key | | `BETTER_I18N_API_URL` | No | Custom API URL (default: dash.better-i18n.com) | | `BETTER_I18N_DEBUG` | No | Enable detailed debug logging | --- # AI Assistants Source: https://help.better-i18n.com/hi/docs/mcp/ai-assistants Better i18n provides a **remote MCP server** that AI assistants can connect to directly via URL. No installation required — just paste the server URL and sign in with your Better i18n account. ``` https://mcp.better-i18n.com/mcp ``` > [!NOTE] > Unlike [coding agents](/mcp/getting-started) that run a local MCP process via `npx`, AI assistants connect to the hosted server over HTTP and authenticate via OAuth. ## Supported Assistants **ChatGPT** ### Open ChatGPT Settings Go to **Settings > MCP Tools** (or click the MCP icon in the chat input). ### Add MCP Server Click **"Add MCP server"** and paste the server URL: ``` https://mcp.better-i18n.com/mcp ``` ### Authorize ChatGPT will redirect you to the Better i18n sign-in page. Log in with your account and authorize access. Once authorized, ChatGPT will discover all available **translation tools** automatically. ### Start Using Ask ChatGPT to manage your translations: > "Show me all projects in my Better i18n workspace." > "Find all missing Turkish translations in my project." > "Create a new key `auth.welcome` with source text 'Welcome back' and translate it to German and French." **Claude** ### Open Claude Settings Go to [claude.ai](https://claude.ai) and navigate to **Settings > Integrations**. ### Add MCP Integration Click **"Add integration"** and enter the server URL: ``` https://mcp.better-i18n.com/mcp ``` ### Authorize Claude will redirect you to sign in to your Better i18n account. Authorize the connection. ### Start Using Ask Claude to work with your translations: > "List my translation projects and show coverage for each language." > "Update the Turkish translation for `nav.home` to 'Ana Sayfa'." > [!NOTE] > This is for **Claude on the web** (claude.ai). For **Claude Desktop** (the desktop app), use the [local MCP setup](/mcp/getting-started) instead, which runs via `npx`. **Gemini** ### Open Gemini Settings Go to [gemini.google.com](https://gemini.google.com) and navigate to **Settings > Extensions** or the MCP tools section. ### Add MCP Server Add the server URL: ``` https://mcp.better-i18n.com/mcp ``` ### Authorize Sign in to your Better i18n account when prompted and authorize access. ### Start Using Ask Gemini to manage your translations: > "What projects do I have in Better i18n?" > "Translate all missing keys in `common` namespace to Japanese." ## How It Works ``` AI Assistant → HTTPS → mcp.better-i18n.com/mcp → Better i18n API │ OAuth 2.1 Auth │ dash.better-i18n.com ``` 1. **Connect**: The AI assistant connects to the remote MCP server over HTTPS. 2. **Authenticate**: OAuth 2.1 flow redirects you to sign in with your Better i18n account. 3. **Discover**: The assistant discovers all available translation tools automatically. 4. **Execute**: When you ask a question, the assistant calls the appropriate tools on your behalf. Your OAuth session lasts **30 days**. After that, the assistant will prompt you to re-authorize. ## Remote vs Local MCP | | Remote (AI Assistants) | Local (Coding Agents) | |---|---|---| | **Setup** | Paste URL + sign in | `npx @better-i18n/mcp` + API key | | **Auth** | OAuth 2.1 (browser sign-in) | API key in env variable | | **Clients** | ChatGPT, Claude, Gemini | Cursor, Claude Desktop, Windsurf | | **Transport** | Streamable HTTP | stdio | | **Use case** | Chat-based translation management | IDE-integrated workflows | Both methods give access to the same set of [translation tools](/mcp/tool-reference). ## Content MCP Server If you also use Better i18n's headless CMS, a separate content MCP server is available: ``` https://mcp-content.better-i18n.com/mcp ``` Add it as a second MCP server in your assistant's settings. It provides 18 content management tools (models, entries, fields, publishing) and uses the same OAuth flow for authentication. Available content tools: `listContentModels`, `getContentModel`, `createContentModel`, `updateContentModel`, `deleteContentModel`, `listContentEntries`, `getContentEntry`, `createContentEntry`, `updateContentEntry`, `deleteContentEntry`, `publishContentEntry`, `duplicateContentEntry`, `bulkCreateEntries`, `bulkPublishEntries`, `addField`, `updateField`, `removeField`, `reorderFields`. ## Troubleshooting ### Tools not showing after connecting Disconnect and reconnect the MCP server in your AI assistant's settings. This forces a fresh tool discovery. ### "Authentication required" when calling tools Your OAuth session may have expired. Disconnect and reconnect to trigger a new sign-in flow. ### Tools visible but returning errors Ensure your Better i18n account has access to at least one project. Try asking the assistant to "list my projects" first. --- # Agent Skill Source: https://help.better-i18n.com/hi/docs/mcp/agent-skill Install the better-i18n skill to give your AI agent built-in knowledge of the platform — SDK setup, key naming conventions, CDN behavior, GitHub sync, MCP tools, and common failure patterns. No need to paste prompts or documentation links each session. > [!NOTE] > **Skill vs MCP:** The skill teaches your AI agent *how* better-i18n works. The [MCP server](/mcp/getting-started) lets your AI agent *act* on your translations. Both can be installed together. ## Install **Claude Code** Run in your terminal or any Claude Code session: ```bash npx skills add better-i18n/skills ``` The skill is loaded automatically in every future Claude Code session. **Cursor** ```bash npx skills add better-i18n/skills -a cursor ``` Or add it via **Cursor Settings → Rules** and paste the skill content from [github.com/better-i18n/skills](https://github.com/better-i18n/skills). **Windsurf** ```bash npx skills add better-i18n/skills -a windsurf ``` **Other** For any AI assistant that supports custom instructions or system prompts, copy the skill content directly from [github.com/better-i18n/skills](https://github.com/better-i18n/skills) and paste it into the system instructions field. ## What the skill covers Once installed, your AI agent knows: - **SDK Setup** — Next.js, React, TanStack Start, Expo, Remix, Swift, Flutter — correct singleton patterns, ISR config, SSR hydration - **Key Naming** — Namespace structure, flat vs nested keys, naming conventions, ICU MessageFormat patterns - **CDN Behavior** — 5-layer fallback chain, { fallback: true } handling, ISR + CDN stale timing, staticData shape requirements - **GitHub Sync** — PR workflows, 422 handling, invalid file patterns, source text changes → draft reset - **MCP Tools** — Tool reference, pagination, API key scopes, createKeys vs updateKeys, common agent mistakes - **Publish Pipeline** — Quality checks, 0% coverage danger, CDN + GitHub dual publish, draft exclusions ## Combine with MCP For the full AI-powered workflow, install both: ```bash # 1. Install the skill (permanent knowledge) npx skills add better-i18n/skills # 2. Configure the MCP server (tool execution) # See: /mcp/getting-started ``` With both active, your AI agent can discuss your i18n setup *and* execute operations like translating keys, checking coverage, and publishing — all without leaving your editor. ## Skill repository The skill is open-source and maintained in the [better-i18n/skills](https://github.com/better-i18n/skills) repository. Updates are published automatically — re-run `npx skills add better-i18n/skills` to get the latest version. --- # Available Tools Source: https://help.better-i18n.com/hi/docs/mcp/tool-reference Better i18n provides two MCP servers with focused toolsets. All project-scoped tools require a `project` parameter in `org/project` format (e.g., `"aliosman-co/personal"`). ## Translation Tools (`@better-i18n/mcp`) > [!NOTE] > Call `listProjects` first to discover available projects before using other tools. ## Discovery Tools List all projects you have access to across all organizations. **Use this first** to discover available projects before other operations. **Returns:** Project slugs, source/target languages, organization names. Get project details including namespaces, languages, key count, and translation coverage. **Returns:** Namespaces, languages with coverage percentages, total key count. ## Reading Tools Browse translation keys in compact, paginated format. Optimized for exploration — use `getTranslations` when you need actual translation text for AI tasks. **Parameters:** - `search` (optional): Search key names by partial match. Single string or array for multi-term OR search (e.g., `["login", "signup"]`). - `namespaces` (optional): Filter by specific namespaces (e.g., `["auth", "common"]`). - `missingLanguage` (optional): Return only keys that DON'T have a translation for this language code (e.g. `"tr"`, `"zh-Hans"`). - `fields` (optional): Fields to include per key. Default: `["id", "sourceText"]`. - `"translatedLanguageCount"` → `tlc: 5` — token-efficient count, ideal for coverage overview - `"translatedLanguages"` → `tl: ["de","fr","tr"]` — full list of translated lang codes - `"translations"` → `tr: {"de":"..."}` — actual translation text (heaviest) - `page` (optional): Page number, 1-indexed (default: 1). - `limit` (optional): Keys per page, max 100 (default: 20). **Response format (compact):** - `tot`: total matching keys | `ret`: returned this page | `has_more`: more pages exist - `nss`: namespace lookup table — each key's `ns` is an index into this array - `k`: key items with `k` (name), `ns` (namespace index), and requested fields **Examples:** ```json // Browse first page { "project": "org/project" } // Find keys missing Turkish { "project": "org/project", "missingLanguage": "tr" } // Coverage overview (token-efficient) { "project": "org/project", "fields": ["id", "translatedLanguageCount"] } // Search with full language list { "project": "org/project", "search": "login", "fields": ["id", "sourceText", "translatedLanguages"] } ``` ## Writing Tools Create one or more translation keys with source text and optional target translations. **Parameters:** - `keys`: Array of keys to create: - `name`: Key name (e.g., `"submit_button"`, `"nav.home"`) - `namespace`: Namespace (optional, default: `"default"`) - `sourceText`: Source language text - `translations`: Target translations object (e.g., `{ "tr": "Gönder", "de": "Senden" }`) Source language text goes in `sourceText`, not in `translations`. ```json { "keys": [ { "name": "auth.login.title", "namespace": "common", "sourceText": "Sign In", "translations": { "tr": "Giriş Yap" } } ] } ``` Update source text and/or target translations. Each entry updates ONE language for ONE key. **Parameters:** - `translations`: Array of translation updates: - `key`: Key name (e.g., `"submit_button"`) - `namespace`: Namespace (optional, default: `"default"`) - `language`: Language code (e.g., `"en"`, `"tr"`, `"de"`) - `text`: New text value - `isSource`: Set `true` to update source text (optional) - `status`: `"published"`, `"pending"`, `"reviewed"` (optional, default: `"published"`) ```json { "translations": [ { "key": "auth.login.title", "language": "en", "text": "Sign In", "isSource": true }, { "key": "auth.login.title", "language": "tr", "text": "Giriş Yap" } ] } ``` Soft-delete translation keys by UUID. Keys are removed from CDN/GitHub on next publish. **Parameters:** - `keyIds`: Array of key UUIDs (1-100). Get UUIDs from `listKeys`. Add one or more target languages to the project. Already-existing languages are silently skipped. **Parameters:** - `languages`: Array of languages to add (1–50): - `languageCode`: ISO 639-1 code (e.g. `"fr"`, `"ja"`) or BCP 47 locale (e.g. `"zh-Hans"`, `"pt-BR"`) - `status` (optional): `"active"` (published to CDN, default) or `"draft"` (visible but not deployed) ```json { "languages": [ { "languageCode": "tr", "status": "active" }, { "languageCode": "zh-Hans" } ] } ``` Update the status of existing target languages — activate, deactivate, or archive them. **Parameters:** - `edits`: Array of status changes (1–50): - `languageCode`: ISO 639-1 or BCP 47 locale code of the language - `newStatus`: `"active"` (published to CDN), `"draft"` (visible but not deployed), or `"archived"` (hidden from editor and CDN) ```json { "edits": [ { "languageCode": "de", "newStatus": "active" }, { "languageCode": "zh-Hans", "newStatus": "archived" } ] } ``` ## Publish Tools > [!WARNING] > **Production Impact:** Publishing deploys changes to live CDN or GitHub. Always call `getPendingChanges` first to verify what will be deployed. Preview what will be deployed before publishing. Shows translations, deleted keys, and publish destination. **Returns:** - `hasPendingChanges`: Boolean indicating if there's anything to publish - `summary`: Object with translation counts, deleted keys, and total changes - `byLanguage`: Breakdown of pending translations by language code - `deletedKeys`: Keys that will be permanently removed on publish - `publishDestination`: `"github"`, `"cdn"`, or `"none"` - `cannotPublishReason`: Error message if publishing is blocked **Workflow:** 1. Call `getPendingChanges` to see what's pending 2. Review the changes 3. Call `publishTranslations` only if changes look correct Deploy pending changes to production (CDN or GitHub). Returns immediately with sync job IDs for tracking. **Parameters:** - `translations` (optional): Array of specific translations to publish with `keyId` and `languageCode`. If omitted, publishes ALL pending changes. **Returns:** - `success`: Boolean indicating if publish was initiated - `published`: Number of translations published - `repositories`: Number of repositories updated - `syncJobIds`: Array of sync job IDs for tracking with `getSync` **Important:** - This is an async operation - jobs typically complete in 5-30 seconds - Use `getSync(syncId)` to verify completion - Deleted keys are permanently removed from CDN/GitHub and database ```json { "translations": [ { "keyId": "uuid-1", "languageCode": "tr" }, { "keyId": "uuid-2", "languageCode": "de" } ] } ``` ## Sync Tools List recent sync operations for a project. **Parameters:** - `limit` (optional): Max results (default: 10, max: 50) - `status` (optional): `"pending"`, `"in_progress"`, `"completed"`, `"failed"` - `type` (optional): `"initial_import"`, `"source_sync"`, `"cdn_upload"`, `"batch_publish"` Get details about a specific sync operation including logs and affected keys. **Parameters:** - `syncId`: Sync job ID from `getSyncs` or `publishTranslations` response. **Returns:** - `id`: Sync job ID - `type`: Job type (e.g., `"batch_publish"`) - `status`: `"completed"`, `"failed"`, `"in_progress"`, `"pending"`, `"cancelled"` - `startedAt`: ISO timestamp - `completedAt`: ISO timestamp (null if still running) - `errorMessage`: Error details if failed - `logs`: Array of log messages - `affectedKeys`: Keys modified in this sync --- ## Content Tools (`@better-i18n/mcp-content`) These tools manage headless CMS content — models, entries, and localized content fields. > [!NOTE] > Content tools are in a separate MCP package. Install `@better-i18n/mcp-content` alongside `@better-i18n/mcp` if you use both translation and content features. ### Model Tools List all content models in a project with entry counts. **Returns:** Array of models with `slug`, `displayName`, `entryCount`, and field definitions. Get a content model's full details including all field definitions. **Parameters:** - `modelSlug`: The model's URL slug (e.g., `"blog-post"`) **Returns:** Model with fields, each containing `name`, `type`, `required`, `description`. Create a new content model with optional initial field definitions. **Parameters:** - `slug`: Model slug (lowercase, hyphens only, e.g., `"blog-posts"`) - `displayName`: Human-readable model name - `description` (optional): Model description - `kind` (optional): `"collection"` (multiple entries, default) or `"single"` (one entry) - `icon` (optional): Icon identifier - `enableVersionHistory` (optional): Enable version history tracking (default: `true`) - `fields` (optional): Array of initial field definitions with `name`, `displayName`, `type`, `localized`, `required`, `options`, `fieldConfig` ```json { "slug": "blog-posts", "displayName": "Blog Posts", "kind": "collection", "fields": [ { "name": "author_name", "displayName": "Author", "type": "text", "required": true }, { "name": "category", "displayName": "Category", "type": "enum", "options": { "enumValues": [{ "label": "Tech", "value": "tech" }] } } ] } ``` Update a content model's metadata, including display settings. **Parameters:** - `modelSlug`: Content model slug to update - `displayName` (optional): Updated display name - `description` (optional): Updated description - `kind` (optional): Updated model kind (`"collection"` or `"single"`) - `icon` (optional): Updated icon identifier - `enableVersionHistory` (optional): Updated version history setting - `tableSettings` (optional): Table display settings for base field column visibility - `baseFields`: Map of base field name → show in table (e.g., `{ "title": true, "slug": false, "body": false }`) > [!WARNING] > `tableSettings.baseFields` is not available in the OSS version. This parameter is ignored when running the open-source MCP server. Delete a content model and all its entries. **Parameters:** - `modelSlug`: Content model slug to delete **Warning:** This permanently deletes the model and all associated entries, fields, and content. ### Field Tools Add a custom field to a content model. Field name must be snake_case. **Parameters:** - `modelSlug`: Parent content model slug - `name`: Field name (snake_case, e.g., `"author_name"`) - `displayName`: Display name (e.g., `"Author Name"`) - `type` (optional): Field type — `text`, `textarea`, `richtext`, `number`, `boolean`, `date`, `datetime`, `enum`, `media`, `relation` (default: `text`) - `localized` (optional): Whether field is localized per language (default: `false`) - `required` (optional): Whether field is required (default: `false`) - `placeholder` (optional): Placeholder text - `helpText` (optional): Help text - `position` (optional): Sort position (auto-calculated if omitted) - `options` (optional): Field-level options - `enumValues`: Allowed values for enum fields — `[{ "label": "Display", "value": "stored" }]` - `showInTable`: Whether this field appears as a column in the content list table - `unsplash`: `{ "enabled": true }` for Unsplash integration - `aiGeneration`: `{ "enabled": true, "prompt": "..." }` for AI generation - `fieldConfig` (optional): Type-specific configuration - `targetModel`: Target model slug for relation fields There is no array or group field type. To model repeating content, do **not** create numbered fields (`feature_1`, `feature_2`, `tag_1_slug`…) — that is an anti-pattern. Use a body **block** (code-first, registered per project, backed by a JSON Schema that supports arrays/nested objects, inserted via the `/` slash picker) for page-composition repetition, or a `relation` field for references to other entries. Update a custom field's properties within a content model. **Parameters:** - `modelSlug`: Parent content model slug - `fieldName`: Field name to update - `displayName` (optional): Updated display name - `type` (optional): Updated field type - `localized` (optional): Updated localization setting - `required` (optional): Updated required setting - `placeholder` (optional): Updated placeholder text - `helpText` (optional): Updated help text - `options` (optional): Updated field-level options - `enumValues`: Updated allowed values for enum fields - `showInTable`: Whether this field appears as a column in the content list table - `unsplash`: Updated Unsplash settings - `aiGeneration`: Updated AI generation settings - `fieldConfig` (optional): Updated type-specific configuration Remove a custom field from a content model. **Parameters:** - `modelSlug`: Parent content model slug - `fieldName`: Field name to remove **Warning:** This permanently removes the field and all its values from existing entries. Reorder custom fields in a content model. **Parameters:** - `modelSlug`: Parent content model slug - `fieldNames`: Array of field names in desired order ### Entry Tools List content entries with filtering and pagination. **Parameters:** - `modelSlug` (optional): Filter by content model - `search` (optional): Text search across title and body - `language` (optional): Language code for translated content - `status` (optional): `"draft"`, `"published"`, or `"archived"` - `missingLanguage` (optional): Return entries that don't have a translation for this language code - `searchLanguages` (optional): Array of language codes to search across when using `search` - `searchInBody` (optional): Whether to include body content in text search (default: `false`) - `expand` (optional): Additional fields to include in the response (e.g., `["customFields", "translations"]`) - `compact` (optional): Return minimal fields only (default: `false`) **Returns:** Paginated list of entries with `id`, `slug`, `title`, `status`, `publishedAt`. Get a single content entry with all translations and custom field values. **Parameters:** - `entryId`: The entry UUID **Returns:** Full entry with `title`, `body`, `bodyMarkdown`, `bodyHtml`, `translations`, `customFields`. Create a new content entry in a model. **Parameters:** - `modelSlug`: Target content model slug - `title`: Entry title - `slug` (optional): URL slug (auto-generated from title if omitted) - `bodyMarkdown` (optional): Entry body as Markdown (automatically converted to HTML and editor JSON) - `translations` (optional): Map of language code → title for multi-language support (e.g., `{ "en": "Hello", "tr": "Merhaba" }`) ```json { "modelSlug": "blog-post", "title": "Getting Started with i18n", "bodyMarkdown": "# Welcome\n\nThis guide covers...", "translations": { "en": "Getting Started with i18n", "tr": "i18n'e Başlarken" } } ``` Update an existing content entry's translations or metadata. **Parameters:** - `entryId`: The entry UUID - `languageCode`: Language to update - `title` (optional): Updated title - `bodyMarkdown` (optional): Updated body as Markdown - `excerpt` (optional): Short summary - `metaTitle` (optional): SEO title - `metaDescription` (optional): SEO description Duplicate an existing content entry within the same model. **Parameters:** - `entryId`: Source entry UUID to duplicate **Returns:** Newly created entry with a generated slug (original slug + `-copy`). ### Publish Tools Publish a content entry to CDN. Sets status to `"published"` and triggers async CDN upload. **Parameters:** - `entryId`: The entry UUID **Returns:** Updated entry with `publishedAt` timestamp. Hard-delete a content entry (irreversible). **Parameters:** - `entryId`: The entry UUID **Warning:** This permanently deletes the entry and all its translations. This action cannot be undone. Create multiple content entries in a single model at once (max 20). Partial success is possible — response reports created count and any failures. **Parameters:** - `modelSlug`: Content model slug (required) - `entries`: Array of entry objects (1–20), each with: - `title`: Entry title (required) - `slug`: URL slug (required) - `bodyMarkdown` (optional): Body content as Markdown - `status` (optional): `"draft"` or `"published"` (default: `"draft"`) - `customFields` (optional): Custom field values - `translations` (optional): Map of language code → `{ title, bodyMarkdown, customFields }` **Returns:** `{ created: number, failed: number, entries: [...], errors: [...] }` Publish multiple content entries at once. **Parameters:** - `entryIds`: Array of entry UUIDs to publish (required) - `modelSlug` (optional): Content model slug (for context/validation only — not required) **Returns:** Array of published entries with updated `publishedAt` timestamps. --- ## Best Practices 1. **Discover First**: Start with `listProjects` → `getProject` to understand the project. 2. **Find Gaps**: Use `listKeys` to see all keys and find missing translations. 3. **Batch Operations**: `createKeys` and `updateKeys` handle single and bulk operations efficiently. 4. **Source Text**: Use `updateKeys` with `isSource: true` to update source text. 5. **Clean Up**: Use `deleteKeys` to remove unused keys (soft delete until publish). 6. **Safe Publishing**: Always call `getPendingChanges` before `publishTranslations` to verify changes. 7. **Track Deployment**: Use `getSync(syncId)` to verify publish jobs completed successfully. 8. **Language Setup**: Use `proposeLanguages` to add new languages, `proposeLanguageEdits` to change status (active/draft/archived). --- # CLI Source: https://help.better-i18n.com/hi/docs/cli Better i18n CLI is a full-featured command-line tool for managing your localization workflow. Authenticate once, then manage translation keys, set translations, publish to CDN, and audit your i18n health — all from the terminal. When MCP tools aren't available (CI/CD pipelines, headless environments, AI agents without MCP), the CLI provides the same capabilities with `--json` output for machine consumption. ## Quick Start ```bash # Install npm install -g @better-i18n/cli # Authenticate (opens browser) better-i18n login # List your projects better-i18n projects # List keys in a project better-i18n keys list -p acme/dashboard # Create keys better-i18n keys create -p acme/dashboard --key "auth.title" --value "Login" # Publish to CDN better-i18n publish -p acme/dashboard ``` ## Authentication The CLI uses the same API keys as the MCP server. Three ways to authenticate: ```bash # Option 1: Browser login (recommended for developers) better-i18n login # Option 2: API key flag (for CI/agents) better-i18n login --api-key bi-your-key # Option 3: Environment variable (for CI/CD) export BETTER_I18N_API_KEY=bi-your-key ``` Auth resolution priority: `--api-key` flag → `BETTER_I18N_API_KEY` env → `~/.better-i18n/auth.json` (from login). [View full authentication docs →](/cli/auth) --- ## Commands ### Auth | Command | Description | |---------|-------------| | [`login`](/cli/auth) | Authenticate via browser or API key | | [`logout`](/cli/auth) | Remove stored credentials | | [`whoami`](/cli/auth) | Show current authenticated user | ### Translation Management | Command | Description | |---------|-------------| | [`projects`](/cli/projects) | List all projects | | [`project`](/cli/projects) | Show project details (languages, namespaces, coverage) | | [`keys list`](/cli/keys) | Browse and search translation keys | | [`keys create`](/cli/keys) | Create keys (flags or stdin JSON) | | [`keys delete`](/cli/keys) | Delete keys by UUID | | [`translations`](/cli/translations) | Get translations with full text content | | [`translate`](/cli/translate) | Set translations for existing keys | ### Publishing | Command | Description | |---------|-------------| | [`publish`](/cli/publish) | Publish pending changes to CDN | | [`publish:status`](/cli/publish) | Show pending changes before publishing | | [`syncs list`](/cli/syncs) | View sync/publish job history | | [`syncs get`](/cli/syncs) | Get sync job details | | [`syncs cancel`](/cli/syncs) | Cancel a pending sync job | ### Languages | Command | Description | |---------|-------------| | [`languages add`](/cli/languages) | Add target languages to project | | [`languages edit`](/cli/languages) | Change language status (active/draft/archived) | ### Code Analysis | Command | Description | |---------|-------------| | [`doctor`](/cli/doctor) | Full i18n health report (score 0–100) | | [`scan`](/cli/scan) | Find hardcoded strings not wrapped in `t()` | | [`check`](/cli/check) | Interactive missing/unused key checker | | [`sync`](/cli/sync) | Compare local keys with remote CDN | | [`pull`](/cli/pull) | Download translations for offline fallback | | [`content:types`](/cli/content-types) | Generate TypeScript types from content models | --- ## Agent / CI Usage Every command supports `--json` for machine-readable output and `--yes` to skip confirmations. Pipe JSON via stdin for bulk operations: ```bash # Agent creates keys echo '[{"n":"auth.title","v":"Login"},{"n":"auth.subtitle","v":"Welcome"}]' \ | better-i18n keys create -p acme/dashboard --json --yes # Agent sets translations echo '[{"id":"","t":{"tr":"Giriş","de":"Anmelden"}}]' \ | better-i18n translate -p acme/dashboard --json --yes # Agent publishes better-i18n publish -p acme/dashboard --json --yes ``` JSON output follows a consistent contract: ```json // Success {"ok": true, "data": { ... }} // Error {"ok": false, "error": "message", "code": "AUTH_FAILED"} ``` --- ## Configuration The CLI reads project settings from `i18n.config.ts` (auto-detected). You can override with `-p org/project` on any command. ```typescript // i18n.config.ts export default { projectId: "acme/dashboard", defaultLocale: "en", }; ``` Credentials are stored in `~/.better-i18n/auth.json` after running `better-i18n login`. ## Installation ```bash # npm npm install -g @better-i18n/cli # npx (no install) npx @better-i18n/cli doctor # pnpm pnpm add -g @better-i18n/cli # bun bun add -g @better-i18n/cli ``` Requires Node.js 18+. --- # Authentication Source: https://help.better-i18n.com/hi/docs/cli/auth The CLI authenticates with the same API keys used by the MCP server. Credentials persist in `~/.better-i18n/auth.json` so you only need to authenticate once. ## Login ### Browser login (recommended) Opens your default browser to authenticate via your Better i18n account (Google, GitHub, or email). ```bash better-i18n login ``` The CLI starts a local callback server on port 9876, opens the dashboard login page, and waits for the authentication to complete. An API key is automatically created and stored locally. ### API key login (CI/agents) Authenticate directly with an API key — no browser needed. ```bash better-i18n login --api-key bi-your-key-here ``` Get your API key from [Settings → API Keys](https://dash.better-i18n.com/settings/api-keys) in the dashboard. ### Environment variable Set `BETTER_I18N_API_KEY` in your environment. The CLI will use it automatically — no `login` required. ```bash export BETTER_I18N_API_KEY=bi-your-key-here better-i18n projects # works immediately ``` ## Auth Resolution Order When the CLI needs authentication, it checks these sources in order: 1. `--api-key` flag (highest priority) 2. `BETTER_I18N_API_KEY` environment variable 3. `~/.better-i18n/auth.json` (from `better-i18n login`) 4. `i18n.config.ts` project config ## Logout Remove stored credentials from your machine. ```bash better-i18n logout ``` ## Whoami Check which account you're authenticated as. ```bash better-i18n whoami ``` ```text Better i18n CLI Email: osman@acme.com Name: Ali Osman Auth: ~/.better-i18n/auth.json API: https://dash.better-i18n.com ``` ## Options | Option | Description | |--------|-------------| | `--api-key ` | API key for direct authentication | | `--api-url ` | Custom API URL (for self-hosted or local dev) | | `--json` | Machine-readable JSON output | ## Credential Storage Credentials are stored as plain JSON in `~/.better-i18n/auth.json`: ```json { "apiKey": "bi-...", "email": "you@example.com", "userId": "...", "apiUrl": "https://dash.better-i18n.com", "createdAt": "2026-05-06T..." } ``` The file is created by `login` and removed by `logout`. It is **not** committed to git — add `~/.better-i18n/` to your global `.gitignore` if needed. --- # projects Source: https://help.better-i18n.com/hi/docs/cli/projects View all projects you have access to and inspect individual project details. ## projects List all projects across all your organizations. ```bash better-i18n projects ``` ```text 39 projects acme/dashboard 8 languages · source: en acme/mobile 22 languages · source: en acme/landing 3 languages · source: en ``` ### Options | Option | Description | |--------|-------------| | `--json` | JSON output | | `--api-key ` | API key | --- ## project Show detailed info for a specific project — languages, namespaces, and coverage. ```bash better-i18n project -p acme/dashboard ``` ```text Acme Dashboard Slug: acme/dashboard Source: en Keys: 262 Languages: 8 Namespaces: nav, auth, common, features, home, about Languages: █ tr 100% Turkish █ de 98% German ▓ fr 85% French ░ ja 42% Japanese ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier (default: from i18n.config.ts) | | `--json` | JSON output | --- # keys Source: https://help.better-i18n.com/hi/docs/cli/keys Manage translation keys in your project. All commands support `--json` for machine-readable output and stdin JSON for bulk operations. ## keys list Browse and search translation keys with pagination. ```bash better-i18n keys list -p acme/dashboard better-i18n keys list -p acme/dashboard --search "auth" better-i18n keys list -p acme/dashboard --namespace common better-i18n keys list -p acme/dashboard --missing tr better-i18n keys list -p acme/dashboard --page 2 --limit 20 ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-s, --search ` | Search keys by name (partial match) | | `-n, --namespace ` | Filter by namespace (comma-separated) | | `-m, --missing ` | Show keys missing translation for this language | | `--page ` | Page number (default: 1) | | `--limit ` | Keys per page (default: 50, max: 100) | | `--json` | JSON output | ### Example Output ```text 262 keys total (showing 10) [nav] about → "About" [auth] login.title → "Log in to your account" [common] save → "Save" Page 1/27 — use --page 2 for more ``` --- ## keys create Create one or more translation keys. Supports inline flags and stdin JSON for bulk operations. ### Inline (few keys) ```bash better-i18n keys create -p acme/dashboard \ --key "auth.title" --value "Login" \ --key "auth.subtitle" --value "Welcome back" \ --namespace auth ``` ### Stdin JSON (bulk / agents) ```bash echo '[ {"n": "auth.title", "v": "Login", "ns": "auth"}, {"n": "auth.subtitle", "v": "Welcome back", "ns": "auth"}, {"n": "common.save", "v": "Save"} ]' | better-i18n keys create -p acme/dashboard --json --yes ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-k, --key ` | Key name(s) to create | | `-v, --value ` | Source text for each key | | `-n, --namespace ` | Namespace (default: `default`) | | `-y, --yes` | Skip confirmation prompt | | `--json` | JSON output | ### JSON Input Schema Each key in the stdin array: | Field | Type | Required | Description | |-------|------|----------|-------------| | `n` | string | yes | Key name (e.g. `auth.login.title`) | | `v` | string | no | Source language text | | `ns` | string | no | Namespace (default: `default`) | | `t` | object | no | Initial translations `{ lang: text }` | --- ## keys delete Delete keys by UUID. Keys are soft-deleted and permanently removed on next publish. ```bash # Single key better-i18n keys delete -p acme/dashboard --ids # Bulk via stdin echo '["uuid-1", "uuid-2", "uuid-3"]' \ | better-i18n keys delete -p acme/dashboard --json --yes ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `--ids ` | Key UUIDs to delete | | `-y, --yes` | Skip confirmation | | `--json` | JSON output | > [!WARNING] > Deleted keys are permanently removed from CDN after `better-i18n publish`. This cannot be undone. --- # translations Source: https://help.better-i18n.com/hi/docs/cli/translations Fetch full translation text for keys — search across all languages, filter by status, or get specific keys by name. ## Usage ```bash # Search in all languages better-i18n translations -p acme/dashboard --search "login" # Get Turkish translations better-i18n translations -p acme/dashboard --languages tr # Find keys missing Turkish better-i18n translations -p acme/dashboard --languages tr --status missing # Get specific keys better-i18n translations -p acme/dashboard --keys "auth.title,auth.subtitle" ``` ## Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-s, --search ` | Search in key names and translation text | | `-l, --languages ` | Comma-separated language codes to return | | `-n, --namespace ` | Filter by namespace | | `--status ` | Filter: `missing`, `draft`, `published`, `all` | | `--keys ` | Comma-separated exact key names | | `--limit ` | Max keys to return (default: 100, max: 200) | | `--json` | JSON output | ## Example Output ```text 6 keys (showing 6) [home] hero.line1 src: Internationalization that ● tr: Gerçekten ● de: Internationalisierung, die [home] hero.subtitle src: Ship multilingual apps in minutes ● tr: Dakikalar içinde çok dilli uygulamalar gönderin ○ de: (draft) ``` ## When to Use | Need | Command | |------|---------| | Browse/search key names | `keys list` (faster, fewer tokens) | | Read actual translation text | `translations` (this command) | | Write translations | `translate` (stdin JSON) | --- # translate Source: https://help.better-i18n.com/hi/docs/cli/translate Write translations for existing keys in bulk. Pipe translation data via stdin — designed for AI agents and automation scripts. ## Usage ```bash echo '[ {"id": "", "t": {"tr": "Giriş yap", "de": "Anmelden"}}, {"id": "", "t": {"tr": "Hoş geldin", "de": "Willkommen"}} ]' | better-i18n translate -p acme/dashboard --json --yes ``` ## Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-y, --yes` | Skip confirmation prompt | | `--json` | JSON output | ## JSON Input Schema Pipe a JSON array via stdin. Each item: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string (UUID) | yes | Translation key UUID (from `keys list --json`) | | `t` | object | yes | Map of `{ languageCode: translationText }` | ## Workflow ```bash # 1. Find keys that need Turkish translation better-i18n keys list -p acme/dashboard --missing tr --json # 2. Generate translations (your AI or script) # 3. Write translations echo '[{"id":"","t":{"tr":"..."}}]' \ | better-i18n translate -p acme/dashboard --json --yes # 4. Publish to CDN better-i18n publish -p acme/dashboard --yes ``` ## Notes - All writes land at `status=approved` - Source language entries are silently ignored — use `keys update` for source text - Max 500 keys per call - Language codes are normalized to lowercase (e.g. `zh-Hans` → `zh-hans`) --- # publish Source: https://help.better-i18n.com/hi/docs/cli/publish Publish pending translation changes to the CDN. After creating or updating translations, changes are staged but not live until you publish. ## publish Deploy all pending changes to CDN. ```bash better-i18n publish -p acme/dashboard ``` Interactive mode shows a summary before publishing: ```text Pending changes in acme/dashboard: + 12 translations - 2 keys deleted Publish to CDN? [Y/n] ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-y, --yes` | Skip confirmation | | `--json` | JSON output | --- ## publish:status Check what changes are pending before publishing. ```bash better-i18n publish:status -p acme/dashboard ``` ```text 2 pending changes + 2 translations to publish Languages: en Run better-i18n publish to deploy. ``` --- ## syncs list View recent sync/publish job history. ```bash better-i18n syncs list -p acme/dashboard better-i18n syncs list -p acme/dashboard --limit 20 ``` ```text 5 recent syncs ✓ 90df897e 5/6/2026, 1:49 AM batch_publish ✓ ca9be32c 5/6/2026, 1:45 AM batch_publish ✗ 56751f79 5/5/2026, 3:35 PM sync · R2 upload failed ``` --- ## syncs get Get details about a specific sync job. ```bash better-i18n syncs get better-i18n syncs get --wait # Block until done (max 15s) ``` --- ## syncs cancel Cancel a pending (not yet started) sync job. ```bash better-i18n syncs cancel --yes ``` > [!NOTE] > Only sync jobs in `pending` status can be cancelled. Once a job starts processing, it runs to completion. --- # syncs Source: https://help.better-i18n.com/hi/docs/cli/syncs Monitor sync and publish jobs. Every `publish` command creates one or more sync jobs that process translations and upload to CDN. ## syncs list View recent sync jobs for a project. ```bash better-i18n syncs list -p acme/dashboard better-i18n syncs list -p acme/dashboard --limit 20 ``` ```text 5 recent syncs ✓ 90df897e 5/6/2026, 1:49 AM batch_publish ✓ ca9be32c 5/6/2026, 1:45 AM batch_publish ✗ 56751f79 5/5/2026, 3:35 PM sync · Upload failed ➳ a3b489ec 5/5/2026, 3:27 PM sync ○ 04d53125 5/4/2026, 11:36 PM batch_publish ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `--limit ` | Number of jobs to show (default: 10) | | `--json` | JSON output | --- ## syncs get Get details about a specific sync job. Use `--wait` to block until the job finishes (useful right after `publish`). ```bash better-i18n syncs get better-i18n syncs get --wait ``` ```text Sync 90df897e-916e-4bec-a6a7-2d59857ac4b0 Status: completed Type: batch_publish Created: 5/6/2026, 1:49:45 AM Done: 5/6/2026, 1:51:08 AM ``` The `--wait` flag blocks up to 15 seconds for the job to reach a terminal state (completed/failed/cancelled). Useful for CI scripts: ```bash # Publish and wait for completion RESULT=$(better-i18n publish -p acme/dashboard --json --yes) SYNC_ID=$(echo $RESULT | jq -r '.data.syncJobIds[0]') better-i18n syncs get $SYNC_ID --wait --json ``` --- ## syncs cancel Cancel a sync job that hasn't started processing yet. ```bash better-i18n syncs cancel --yes ``` Only jobs in `pending` status can be cancelled. Once processing starts, the job runs to completion. ### Options | Option | Description | |--------|-------------| | `--wait` | Block until sync completes (max 15s) | | `-y, --yes` | Skip confirmation | | `--json` | JSON output | --- # languages Source: https://help.better-i18n.com/hi/docs/cli/languages Add target languages to a project or change their status. ## languages add Add one or more target languages. ```bash # Add individual languages better-i18n languages add -p acme/dashboard --lang fr --lang de --lang ja # Add as draft (not published to CDN) better-i18n languages add -p acme/dashboard --lang ko --status draft # Bulk via stdin echo '[{"languageCode":"fr"},{"languageCode":"de","status":"draft"}]' \ | better-i18n languages add -p acme/dashboard --json --yes ``` ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-l, --lang ` | Language codes (ISO 639-1 or BCP 47) | | `--status ` | Initial status: `active` (default) or `draft` | | `-y, --yes` | Skip confirmation | | `--json` | JSON output | --- ## languages edit Change the status of existing languages. ```bash # Archive a language (remove from CDN) better-i18n languages edit -p acme/dashboard --lang ko --new-status archived # Reactivate better-i18n languages edit -p acme/dashboard --lang ko --new-status active ``` ### Statuses | Status | CDN | Dashboard | Description | |--------|-----|-----------|-------------| | `active` | Published | Visible | Default. Translations are live on CDN. | | `draft` | Not published | Visible | Work in progress. Visible in editor but not deployed. | | `archived` | Not published | Hidden | Removed from editor and CDN. Data preserved. | ### Options | Option | Description | |--------|-------------| | `-p, --project ` | Project identifier | | `-l, --lang ` | Language codes to edit | | `--new-status ` | New status: `active`, `draft`, or `archived` | | `-y, --yes` | Skip confirmation | | `--json` | JSON output | --- # scan Source: https://help.better-i18n.com/hi/docs/cli/scan Scan your codebase for hardcoded strings that need translation. Better i18n CLI uses a robust AST parser to differentiate between UI text and developer symbols. ## Usage ```bash better-i18n scan # Scan current directory better-i18n scan --dir ./src # Scan specific directory better-i18n scan --format json # JSON output for tooling better-i18n scan --ci # Exit code 1 if issues found better-i18n scan --staged # Only staged files better-i18n scan --verbose # Detailed output with scan stats ``` ## Options | Option | Description | |--------|-------------| | `--dir ` | Directory to scan | | `--format ` | `eslint` (default) or `json` | | `--ci` | Exit with code 1 if issues found. Useful for blocking PRs. | | `--staged` | Only scan git staged files. Perfect for pre-commit hooks. | | `--verbose` | Shows detailed output and a **Scan Audit** summary. | ## Example Output ```text ✓ Project: better-i18n/landing ✓ Found 246 files components/sign-up.tsx (3) 24:13 missing "Create an account" i18n/jsx-text 32:22 missing "Name" i18n/jsx-text 40:22 missing "Email" i18n/jsx-text ✖ 87 problems (87 missing translations) Scanned 246 files in 0.85s ``` ### Scan Details (with --verbose) When running with `--verbose`, the CLI provides a breakdown of its discovery process: ```text 🔍 Scan Details: - Root-scoped translators: 4 - Unbound translators: 0 - Dynamic namespaces skipped: 12 - Dynamic keys skipped: 5 ``` ## Detection Rules | Rule | Catches | Example | |------|---------|---------| | `jsx-text` | Hardcoded JSX text | `

Hello

` | | `jsx-attribute` | Hardcoded attributes | `Logo` | | `toast-message` | Toast notifications | `toast.error("Failed")` | | `ternary-locale` | Locale-based logic | `locale === 'en' ? 'Hi' : 'Hola'` | | `string-variable` | String variables | `const x = "Hello"` | ## Ignored Patterns The CLI is smart enough to ignore common developer symbols and technical strings: - **HTML entities**: `"`, `&`, `'` - **Tailwind/CSS classes**: `className="flex items-center bg-blue-500"` - **URLs & Paths**: `href="https://..."`, `src="/images/..."` - **Technical strings**: `SCREAMING_CASE`, variables, and numbers. ## CI/CD Integration ### GitHub Actions Enforce zero hardcoded strings in your main branch: ```yaml name: i18n Check on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npx @better-i18n/cli scan --ci ``` ### Pre-commit Hook Catch issues before they are even committed: ```bash # Using husky npx husky init echo "npx @better-i18n/cli scan --staged --ci" > .husky/pre-commit ``` --- # check Source: https://help.better-i18n.com/hi/docs/cli/check Focused translation key checker with interactive prompts and targeted analysis. Check for missing keys, unused keys, or both with a user-friendly interface. ## When to use check The `check` command provides a streamlined experience for specific auditing scenarios: - **Before pushing code**: Quickly verify new translation keys are uploaded. - **Cleaning up translations**: Focus only on unused keys without noise. - **Interactive workflow**: Let the CLI guide you through what to check. - **Targeted analysis**: Get only the report you need (missing OR unused). - **First-time users**: Interactive prompts make it easy to get started. --- ## Commands ### `better-i18n check` Interactive mode - the CLI asks what you want to check and shows only relevant results. ```bash better-i18n check # Interactive prompt: ? What would you like to check? ❯ Missing translation keys Unused translation keys Both (Full Comparison) ``` ### `better-i18n check:missing` Check for keys used in code but not in Better i18n remote (skips unused analysis). ```bash better-i18n check:missing # Output includes: # ✓ Coverage: Local → Remote # ⊕ Missing in Remote (N keys) ``` ### `better-i18n check:unused` Check for keys in Better i18n but not detected in code (skips missing analysis). ```bash better-i18n check:unused # Output includes: # ✓ Coverage: Remote Used # 🔍 Used via Dynamic Patterns # ⊖ Possibly Unused (N keys) ``` --- ## Difference from `sync` | Feature | `check` | `sync` | |---------|---------|--------| | **Interactive** | ✅ Asks what to check | ❌ Always full comparison | | **Targeted reports** | ✅ Only shows what you need | ❌ Shows everything | | **Subcommands** | ✅ `check:missing`, `check:unused` | ❌ Single command | | **Best for** | Quick focused checks | Full audits & CI | | **Output** | Filtered by selection | Complete comparison | **Use `check` when**: You want a quick, focused check with interactive guidance. **Use `sync` when**: You need the full picture or running in CI/CD. --- ## Usage ```bash # Interactive - asks what to check better-i18n check # Direct - check only missing keys better-i18n check:missing # Direct - check only unused keys better-i18n check:unused # With options better-i18n check:missing --verbose better-i18n check:unused -d ./src better-i18n check --format json ``` ## Options All `check` commands support the same options: | Option | Description | |--------|-------------| | `-d, --dir ` | Directory to scan (default: current directory) | | `--format ` | Output format: `eslint` (human-readable) or `json` (machine) | | `--verbose` | Show detailed audit log with scoping summary and key probes | --- ## Output Examples ### Missing Keys Only ```bash $ better-i18n check:missing ``` ```text 🔍 Checking for Missing Translation Keys Keys used in code but not in Better i18n remote ⠋ Extracting keys... (120/120) ✔ Extracted 1223 keys from 120 files ✔ Fetched 750 keys from remote 📊 Translation Keys Comparison Source locale: en Coverage: Local → Remote: 59% (keys in code that exist in remote) ⊕ Missing in Remote (473 keys) Keys used in code but not uploaded to Better i18n pages (300) affordableEnglishLearning (meta.title, meta.description, meta.keywords, ...+12) bestApps (hero.badge, title_prefix, title_accent, subtitle) hero (5) hero (ariaLabel, imageAlt, ...) Scanned 120 files in 0.85s ✓ Comparison complete ``` ### Unused Keys Only ```bash $ better-i18n check:unused ``` ```text 🔍 Checking for Unused Translation Keys Keys in Better i18n but not detected in code ⠋ Extracting keys... (120/120) ✔ Extracted 1223 keys from 120 files ✔ Fetched 750 keys from remote 📊 Translation Keys Comparison Source locale: en Coverage: Remote Used: 63% (remote keys detected in code) 🔍 Used via Dynamic Patterns (127 keys) These keys are accessed through template literals like t(`plans.${x}.name`) Detected patterns: plans.${planId}.name (15 keys) └─ PricingPage.tsx:42 → plans.starter.name → plans.pro.name → plans.enterprise.name ... and 12 more ⚠️ WARNING: Do NOT delete these keys without manual verification! The CLI detected these keys through pattern matching, but cannot guarantee 100% accuracy. Review the source code before deleting any keys. ⊖ Possibly Unused (386 keys) Static keys not detected in code - safe to review for deletion features (25) features.practiceSpeaking.title features.practiceSpeaking.subtitle features.practiceSpeaking.icon ... and 22 more Scanned 120 files in 0.85s ✓ Comparison complete ``` ### Interactive Mode ```bash $ better-i18n check ``` ```text 🔍 Better i18n Translation Checker ? What would you like to check? ❯ Missing translation keys Unused translation keys Both (Full Comparison) # After selection, shows the appropriate filtered report ``` --- ## Workflow Examples ### Before Opening a PR ```bash # Quick check for missing keys better-i18n check:missing # If issues found, add them via Dashboard/AI # Then verify better-i18n check:missing # Should show 0 missing ``` ### Cleaning Up Old Translations ```bash # Focus only on unused keys better-i18n check:unused # Review the "Possibly Unused" section # Check dynamic patterns warnings # Delete safe-to-remove keys via Dashboard ``` ### First Time Running ```bash # Interactive mode guides you better-i18n check # Choose what matters most right now: # - Missing: Ensure your new features are translated # - Unused: Clean up technical debt # - Both: Full audit (same as 'sync') ``` --- ## Advanced: Dynamic Pattern Detection The `check:unused` command includes intelligent pattern matching for template literals: ```tsx // This code pattern... const planId = 'pro'; t(`plans.${planId}.name`); // Dynamic key access // ...will detect these remote keys as "used": // - plans.starter.name // - plans.pro.name // - plans.enterprise.name ``` **Why this matters**: Keys matched by dynamic patterns are NOT marked as "unused" even if no static `t('plans.pro.name')` call exists. The CLI shows these as "Used via Dynamic Patterns" with a warning to review before deletion. --- ## JSON Output All `check` commands support `--format json`: ```bash better-i18n check:missing --format json | jq '.comparison.missingCount' # Output: 473 better-i18n check:unused --format json | jq '.comparison.possiblyUnusedCount' # Output: 386 ``` --- ## Related Commands - **[`sync`](/cli/sync)** - Full comparison with both missing and unused (default behavior) - **[`scan`](/cli/scan)** - Find hardcoded strings that need translation --- # sync Source: https://help.better-i18n.com/hi/docs/cli/sync Compare your codebase's `t()` calls with translations stored in Better i18n (remote) and audit your project for consistency. ## When to use sync The `sync` command is a powerful auditing tool. Common scenarios include: - **Before opening a PR**: Ensure all new translation keys are present in Better i18n. - **After adding new pages**: Automatically identify and log missing keys. - **After refactors**: Detect "dead" keys that are no longer referenced in code. - **Before shipping a new locale**: Verify that the source keys are stable. - **Debugging**: Use `--verbose` to trace why certain keys are not being picked up. - **CI Gating**: Use `--format json` to fail builds if missing keys are detected. --- ## Workflow 1. **Run `sync`**: See the tree of missing or unused keys. 2. **Dashboard/AI**: Add the missing keys via the Better i18n dashboard or AI suggestions. 3. **Verify**: Run `sync --summary` to confirm `missingCount` is 0. 4. **Enforce**: Integrate into CI to prevent missing keys from reaching production. --- ## Usage ```bash better-i18n sync # Grouped tree output (default) better-i18n sync --summary # High-level metrics only better-i18n sync --verbose # Deep audit & verification better-i18n sync --format json # JSON output for automation better-i18n sync -d ./src # Scan specific directory ``` ## Options | Option | Description | |--------|-------------| | `--format ` | `eslint` (default) for humans, `json` for machines. | | `--verbose` | Detailed audit log: invariant checks (PASS/FAIL), scoping summary, dynamic key skips, and specific key probes. | | `--summary` | Show only top-level percentage coverage and counts without the detailed tree. | | `-d, --dir ` | Specify the directory to scan (default: current directory). | --- ## Output Example The default output provides a 2-level compact tree of mismatches. ```text 📊 Translation Keys Comparison Source locale: en Coverage: Local → Remote: 59% Remote Used: 63% ⊕ Missing in Remote (473 keys) Keys used in code but not uploaded to Better i18n pages (300) affordableEnglishLearning (meta.title, meta.description, meta.keywords, hero.badge, ...+12) bestApps (hero.badge, title_prefix, title_accent, subtitle) hero (5) hero (ariaLabel, imageAlt, ...) ⊖ Unused in Code (386 keys) Keys in Better i18n but not detected in code usage features (25) practiceSpeaking (title, subtitle, icon) Scanned 246 files in 0.85s ✓ Comparison complete ``` --- ## How namespace binding works The CLI uses **Lexical Scope Tracking** to resolve namespaces automatically for both client and server components. ### Client Components (React Hooks) ```tsx // All t() calls inside this scope become 'hero.key' const t = useTranslations('hero'); return

{t('title')}

; // Detected as: hero.title ``` ### Server Components (Async Functions) ```tsx // ✅ Direct string namespace const t = await getTranslations('welcome'); return

{t('title')}

; // Detected as: welcome.title // ✅ Object with namespace property const t = await getTranslations({ locale: params.locale, namespace: 'maintenance' }); return

{t('message')}

; // Detected as: maintenance.message // ⚠️ Root scoped (no namespace) const t = await getTranslations(); return

{t('global.title')}

; // Detected as: global.title (root scope) ``` ### Supported Patterns - **`useTranslations('namespace')`** - Client component with namespace - **`getTranslations('namespace')`** - Server component with namespace - **`getTranslations({ locale, namespace: 'namespace' })`** - Server with locale - **`useTranslations()` / `getTranslations()`** - Root scoped (no namespace prefix) - **Standard Methods**: All patterns support `t()`, `t.raw()`, `t.rich()`, and `t.has()`. ### Dynamic Namespaces If a namespace is a variable or template literal (e.g., ``useTranslations(`pages.${slug}`)``), it is reported as **unknown-scoped** in `--verbose` mode and skipped from metrics to prevent false positives. --- ## Flattening rules To ensure CLI metrics match the Better i18n UI: 1. **Primitives are leaves**: Strings, numbers, and booleans are counted as keys. 2. **Arrays are leaves**: `meta.keywords: ["i18n", "cli"]` is counted as **one key**, not two. This matches the UI's "Base Key" counting. 3. **Objects are containers**: Keys like `pages.home.hero` representing a grouping of sub-keys are ignored in the final key count. --- ## JSON Output Use `--format json` for CI scripts and automation. ```json { "project": { "workspace": "my-org", "slug": "my-app", "sourceLocale": "en" }, "localKeys": { "total": 1223 }, "comparison": { "missingInRemote": { "pages.bestApps": ["pages.bestApps.meta.title"] }, "unusedInCode": { "old": ["old.unused_key"] }, "missingCount": 473, "unusedCount": 386 }, "coverage": { "local": 59, "remote": 63 }, "files": 246, "duration": 850 } ``` --- ## CI Integration ### Fail build on missing keys ```bash # Using jq to check missingCount better-i18n sync --format json | jq -e '.comparison.missingCount == 0' > /dev/null || exit 1 ``` ### List missing keys in CI logs ```bash better-i18n sync --format json | jq '.comparison.missingInRemote' ``` --- # doctor Source: https://help.better-i18n.com/hi/docs/cli/doctor Run a full i18n health check on your project in one command. `doctor` combines five analysis layers — code scanning, coverage, quality, performance, and CDN sync — and produces a single health score with actionable diagnostics. ## When to use `doctor` - **Before opening a PR**: Catch missing translations, hardcoded strings, and placeholder mismatches before review. - **In CI pipelines**: Block merges when the health score drops below the pass threshold. - **Orphan key audits**: Identify translation keys that are no longer used in code. - **Deployment checks**: Verify that local keys are in sync with the remote CDN before shipping. - **First health baseline**: Run once on an existing project to understand its i18n debt. --- ## Usage ```bash better-i18n doctor # Full analysis of current directory better-i18n doctor --dir ./src # Scan a specific directory better-i18n doctor --format json # JSON output for machine consumption better-i18n doctor --ci # Exit code 1 if score below threshold better-i18n doctor --report # Upload report to Better i18n dashboard better-i18n doctor --report --api-key $KEY # Upload with explicit API key better-i18n doctor --skip-sync # Skip CDN comparison better-i18n doctor --skip-code # Skip hardcoded string detection better-i18n doctor --skip-health # Skip translation file analysis better-i18n doctor --verbose # Detailed per-file diagnostic output ``` ## Options | Option | Description | |--------|-------------| | `-d, --dir ` | Directory to scan (default: current directory) | | `-f, --format ` | Output format: `eslint` (human-readable, default) or `json` (machine) | | `--ci` | Exit with code 1 if health score is below the pass threshold (70) | | `--report` | Upload the report to Better i18n dashboard for tracking over time | | `--api-key ` | API key for report upload. Falls back to GitHub Actions OIDC if omitted. | | `--skip-code` | Skip AST-based hardcoded string detection | | `--skip-health` | Skip translation file health checks (coverage, quality, orphan keys) | | `--skip-sync` | Skip remote CDN comparison | | `--verbose` | Show detailed per-file diagnostics and verbose scan stats | --- ## What it checks `doctor` runs five categories of analysis in a single pass. ### Code — hardcoded string detection Scans your source files with an AST parser to detect user-facing strings that are not wrapped in a translation function. | Rule | What it catches | Example | |------|-----------------|---------| | `jsx-text` | Hardcoded text nodes in JSX | `

Welcome back

` | | `jsx-attribute` | Hardcoded attribute values | `Company logo` | | `ternary-locale` | Inline locale-based string logic | `locale === 'en' ? 'Hi' : 'Hola'` | | `toast-message` | Hardcoded toast/notification text | `toast.error("Something went wrong")` | | `string-variable` | String variables assigned to UI text | `const label = "Submit"` | ### Coverage — missing translations Compares keys present in your source locale against each target locale. Any key that exists in the source but is missing from a target locale is reported. ### Quality — placeholder mismatch Verifies that interpolation placeholders are consistent across locales. Supports all common formats: | Format | Example | |--------|---------| | Named `{}` | `{name}`, `{count}` | | Double-brace `{{}}` | `{{username}}` | | printf `%s` | `%s`, `%d` | | Template `${}` | `${value}` | | Positional `{0}` | `{0}`, `{1}` | A source key with `Hello, {name}!` and a translation with `Hola!` (placeholder removed) is a quality error. ### Performance — orphan keys Detects keys that exist in your translation files (or remote CDN) but are never referenced in code. Orphan keys increase payload size and create maintenance debt. ### Sync — CDN comparison Compares keys extracted from your code against the published keys in the Better i18n CDN. Requires `workspaceId` and `projectSlug` in `i18n.config.ts`. | Issue | Meaning | |-------|---------| | `missing-in-remote` | Key used in code but not yet published to CDN | | `unused-remote-key` | Key published to CDN but not found in code | --- ## Health Score `doctor` computes a score from 0 to 100 based on the diagnostics found. **Overall score formula:** ``` score = 100 - (errors × 3.0) - Σ min(rule_warnings × 0.15, 20) ``` Each rule's warning contribution is **capped at 20 points**. This prevents a single rule with thousands of warnings (e.g. `missing-in-remote` after a large migration) from zeroing your entire score. **Grade thresholds:** | Grade | Score range | CI result | |-------|------------|-----------| | A+ | ≥ 90 | Pass | | A | ≥ 80 | Pass | | B | ≥ 70 | Pass | | C | ≥ 50 | Fail | | F | < 50 | Fail | The default pass threshold is **70**. Scores below this cause `--ci` to exit with code 1. --- ## Output Example ```text ╭──────────────────────────────────────────────╮ │ │ │ 🌐 better-i18n · i18n Doctor Report │ │ hello · hola · 你好 · こんにちは · 안녕 │ │ │ ├──────────────────────────────────────────────┤ │ ████████████████░░░░ 82 / 100 A │ │ PASSED (threshold: 70) │ ╰──────────────────────────────────────────────╯ Category Scores: Coverage 95 (3 issues) Quality 88 (2 issues) Code 72 (8 issues) Structure 100 (clean) Performance 91 (1 issues) 8 warnings, 6 info 214 files scanned, 1847 keys checked, 5 locales Completed in 1.24s ⚠ Hardcoded JSX text detected (8) Wrap with t() to enable translation src/components/Navbar.tsx: 12, 34 src/pages/settings.tsx: 88 src/components/Footer.tsx: 6, 19 ... and 5 more files ⚠ Key "auth.signup.cta" found in code but not in remote translations (3) Run `better-i18n sync` to upload missing keys default/auth.signup.cta default/auth.login.subtitle default/onboarding.step3.title ``` --- ## JSON Output Use `--format json` to get machine-readable output for custom tooling or dashboards. ```bash better-i18n doctor --format json better-i18n doctor --format json | jq '.score.total' better-i18n doctor --format json | jq '[.diagnostics[] | select(.severity == "error")]' ``` The JSON report follows this structure: ```json { "runAt": "2025-03-12T10:00:00.000Z", "durationMs": 1240, "git": { "commit": "a1b2c3d", "ref": "main", "repository": "org/repo" }, "score": { "total": 82, "passed": true, "passThreshold": 70, "categories": { "Coverage": 95, "Quality": 88, "Code": 72, "Structure": 100, "Performance": 91 } }, "summary": { "total": 14, "errors": 0, "warnings": 8, "infos": 6, "byCategory": { "Code": 8, "Coverage": 3, "Quality": 2, "Performance": 1 }, "filesScanned": 214, "keysChecked": 1847, "localesChecked": 5 }, "diagnostics": [ { "filePath": "src/components/Navbar.tsx", "line": 12, "column": 8, "rule": "jsx-text", "category": "Code", "severity": "warning", "message": "Hardcoded JSX text: \"Sign in\"", "help": "Wrap with t() to enable translation" } ] } ``` --- ## CI Integration ### GitHub Actions — automatic auth When running in GitHub Actions with OIDC enabled, `--report` authenticates automatically without requiring an explicit API key: ```yaml name: i18n Health Check on: [push, pull_request] permissions: id-token: write # Required for OIDC jobs: i18n-doctor: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - run: npx @better-i18n/cli doctor --ci --report ``` ### Manual API key ```yaml - run: npx @better-i18n/cli doctor --ci --report --api-key ${{ secrets.BETTER_I18N_API_KEY }} ``` ### Exit code behavior When `--ci` is set: - **Score ≥ threshold**: exits with code 0 (pass) - **Score < threshold**: exits with code 1 (fail) - **`--report` succeeds**: exits with code 0 even if score fails — the report is uploaded to the dashboard for tracking This last rule lets you start tracking health without immediately blocking CI. Once your baseline is established, remove `--report` (or add both) to enforce the threshold. --- ## Configuring Rules Disable or downgrade rules in `i18n.config.ts` under the `lint.rules` key: ```ts title="i18n.config.ts" export default defineConfig({ // ... lint: { rules: { // Turn off rules that don't apply to your project "orphan-keys": "off", "string-variable": "off", // Downgrade from error to warning "missing-translations": "warning", "placeholder-mismatch": "warning", }, }, }); ``` | Value | Behavior | |-------|----------| | `"error"` | Counts toward error penalty (−3.0 per occurrence) | | `"warning"` | Counts toward warning penalty (capped at −20 per rule) | | `"off"` | Rule is skipped entirely | --- ## Skipping Analysis Steps Use skip flags to run only the analysis layers you care about: ```bash # Only check translation file health (no code scan, no CDN) better-i18n doctor --skip-code --skip-sync # Only check CDN sync status (fast — no AST parsing) better-i18n doctor --skip-code --skip-health # Only scan for hardcoded strings better-i18n doctor --skip-health --skip-sync ``` --- ## Related Commands - **[`scan`](/cli/scan)** — Focused hardcoded string detection with `--staged` support for pre-commit hooks - **[`check`](/cli/check)** — Interactive checker for missing or unused keys - **[`sync`](/cli/sync)** — Full local ↔ remote key comparison with upload support --- # pull Source: https://help.better-i18n.com/hi/docs/cli/pull Download translations from the Better i18n CDN to local JSON files — the offline safety net for mobile apps, and the build-time message files for web frameworks like next-intl. ## Why `pull`? **Mobile** (Expo, React Native, Swift, Flutter) depends on CDN translations at runtime. If the CDN is unreachable — airplane mode, slow network, or App Store Review environment — the app needs a fallback. `pull` downloads translations so you can bundle them as `staticData`. ``` CDN available → Fresh translations (best case) CDN unavailable → Persistent cache (MMKV/AsyncStorage) First launch, no cache, no network → staticData from pull ✅ ``` > [!WARNING] > **App Store Review risk:** Apple reviewers may test your app in environments where external CDN calls timeout. Without bundled translations, your app shows raw keys like `common.button.save` — and gets rejected as "incomplete" under Guideline 2.1. **Web** frameworks that read message files off disk at build time (next-intl, i18next, vue-i18n) use `pull` as the step that fetches them. No export or transform: the CDN already serves the shape those loaders expect. See [Web Workflow](#web-workflow). ## Usage ```bash # Auto-detect project from i18n.config.ts or initBetterI18n() call better-i18n pull # Explicit project (no config file needed) better-i18n pull -p acme/my-app # Custom output directory better-i18n pull -o ./src/locales # Download specific locales only better-i18n pull -l en,tr,de ``` ### Example output ```text ✔ Project: acme/my-app ✔ Manifest: 3 languages (en, de, tr) ✔ Downloaded 3 locale(s) to locales/ ✓ en 1867 keys 145.8 KB ✓ de 1742 keys 146.3 KB ✓ tr 1741 keys 131.0 KB Use as offline fallback in your app: staticData: { en: require('./locales/en.json'), de: require('./locales/de.json'), tr: require('./locales/tr.json') } ``` ## Options | Flag | Description | Default | |------|-------------|---------| | `-p, --project ` | Project identifier | From `i18n.config.ts` | | `-o, --output ` | Output directory for JSON files | `./locales` (or config `pull.output`) | | `-l, --locales ` | Comma-separated locale codes | All languages from manifest | | `-d, --dir ` | Directory to scan for config | Current directory | | `--verbose` | Show per-locale download details | Off | ## Configuration Add a `pull` section to your `i18n.config.ts` to set defaults: ```ts title="i18n.config.ts" export const i18nConfig = { projectId: "acme/my-app", defaultLocale: "en", pull: { // [!code highlight] output: "./locales", // [!code highlight] locales: ["en", "tr", "de"], // [!code highlight] }, // [!code highlight] }; ``` CLI flags always override config values. ## Mobile Workflow ### Expo / React Native After pulling, import the JSON files as `staticData`: ```ts title="lib/i18n.ts" import en from './locales/en.json'; import tr from './locales/tr.json'; await initBetterI18n({ projectId: 'acme/my-app', i18n, storage: storageAdapter(new MMKV()), staticData: { en, tr }, // [!code highlight] }); ``` ## Web Workflow What the CDN serves is already the shape web i18n libraries expect: top-level namespaces, nested keys underneath, no dotted keys. For **next-intl** that means no export and no transform step — pull straight into the folder it already reads: ```bash npx @better-i18n/cli@latest pull -p acme/my-app -o ./messages ``` ```text messages/ en.json es.json ``` ```json title="messages/en.json" { "HomePage": { "title": "Hello world!" }, "Nav": { "pricing": "Pricing", "docs": "Docs" } } ``` That file works unchanged with `useTranslations("HomePage")`. The same holds for i18next (`resources`) and vue-i18n (`messages`) — point `-o` at whatever directory your loader reads. ## CI/CD Integration Add `pull` to the build so bundled translations always match the CDN: ```bash title="CI script" npx @better-i18n/cli@latest pull -p acme/my-app -o ./messages ``` > [!TIP] > Run `pull` before every build. If your team edits translations in the dashboard, this is what keeps the deployed bundle from drifting behind them. ### Without the CLI The CDN is public and needs no token, so a plain fetch works if you would rather not add a dependency: ```bash curl -sf https://cdn.better-i18n.com/acme/my-app/en/translations.json -o messages/en.json curl -sf https://cdn.better-i18n.com/acme/my-app/es/translations.json -o messages/es.json ``` Files are served with an `ETag` and a 60-second cache, so conditional requests are cheap. To avoid hardcoding the locale list, read the manifest — it carries every language plus per-locale `keyCount` and `lastModified`, which also makes a good cache key: ```bash curl -s https://cdn.better-i18n.com/acme/my-app/manifest.json ``` Keep the `-f`: without it `curl` writes an error page over your message file and the build carries on. ## Failure Behaviour Since **0.6.2**, `pull` fails a locale rather than writing an empty file when the manifest expects keys and the CDN returns none: ```text ✗ en CDN returned no keys, but the manifest expects 140 ``` The reason prints without `--verbose` and the process exits non-zero, so a broken fetch breaks the build instead of shipping an app with no strings. Nothing is written for a failed locale — the file already on disk stays intact. > [!WARNING] > On 0.6.1 and earlier, a wrong CDN path could answer with an empty `{}` and a `200`, which `pull` wrote out and reported as success. If you pin the CLI version in CI, move to 0.6.2 or later. ## Project Detection The `pull` command finds your project in this order: 1. `-p` flag (highest priority) 2. `i18n.config.ts` in the target directory 3. Source file scan for `initBetterI18n()` or `createI18n()` calls This means Expo projects work without a config file — the CLI finds `projectId: 'acme/my-app'` from your `initBetterI18n()` call automatically. --- # Configuration Source: https://help.better-i18n.com/hi/docs/cli/configuration Better i18n CLI automatically detects your project context from `i18n.config.ts`. This file is the source of truth for your translation workflow. ## Basic Configuration For simple projects, you only need to export the project slug and default locale. ```ts title="i18n.config.ts" export const project = "your-org/your-project"; // [!code highlight] export const defaultLocale = "en"; // [!code highlight] ``` ## Workspace Configuration For full control over scanning and linting behavior, use the `i18nWorkspaceConfig` export. ```ts title="i18n.config.ts" export const project = "your-org/your-project"; export const defaultLocale = "en"; export const i18nWorkspaceConfig = { // [!code highlight] project, // [!code highlight] defaultLocale, // [!code highlight] lint: { // Glob patterns for files to scan include: ["src/**/*.tsx", "app/**/*.tsx"], // Files to explicitly ignore (merges with defaults) exclude: [ "**/skeletons.tsx", // UI skeletons "**/*.stories.tsx", // Storybook "**/*.test.tsx", // Tests "**/components/ui/**", // UI library components (e.g., shadcn) ], // Rule configuration rules: { "jsx-text": "warning", // Show as warning "jsx-attribute": "warning", "ternary-locale": "error", // Block CI if detected }, }, }; ``` ## Pull Options Configure default behavior for the `better-i18n pull` command, which downloads translations from CDN to local JSON files. ```ts title="i18n.config.ts" export const i18nConfig = { projectId: "your-org/your-project", defaultLocale: "en", pull: { // [!code highlight] output: "./locales", // Output directory for JSON files // [!code highlight] locales: ["en", "tr", "de"], // Specific locales (default: all) // [!code highlight] }, // [!code highlight] }; ``` | Option | Type | Description | |--------|------|-------------| | `pull.output` | `string` | Directory for downloaded JSON files. Default: `./locales` | | `pull.locales` | `string[]` | Specific locales to download. Default: all from CDN manifest | CLI flags (`-o`, `-l`) override these config values. ## Lint Options | Option | Type | Description | |--------|------|-------------| | `lint.include` | `string[]` | Files to scan. Default: `["src", "app", "components", "pages"]` | | `lint.exclude` | `string[]` | Files to ignore. These are merged with system defaults like `node_modules`. | | `lint.rules` | `object` | Customize the severity of detection rules. | ## Rule Severities You can set each rule to one of three levels: - **`error`**: Reports as an error and causes `better-i18n scan --ci` to fail with exit code 1. - **`warning`**: Reports as a warning but does not fail the CI (unless `--ci` is combined with specific flags). - **`off`**: Disables the rule entirely. ## Default Exclusions The CLI automatically ignores these directories to ensure high performance and fewer false positives: - `node_modules/**` - `.next/**` (Next.js build artifacts) - `dist/**` / `build/**` (Production bundles) - `.git/**` - `public/**` (Static assets) --- # content:types Source: https://help.better-i18n.com/hi/docs/cli/content-types Generate TypeScript type definitions from your content models — like `supabase gen types typescript`. ## Usage ```bash better-i18n content:types # Auto-detect project from i18n.config.ts better-i18n content:types --project acme/landing # Explicit project better-i18n content:types --output types/cms.ts # Custom output path ``` ## Options | Option | Description | |--------|-------------| | `--project ` | Project identifier. Default: reads from `i18n.config.ts` | | `--api-key ` | Content API key. Default: `BETTER_I18N_API_KEY` env var | | `--output ` | Output file path. Default: `src/content-types.ts` | | `--dir ` | Directory to scan for config. Default: current directory | ## Setup ### 1. Add your API key The CLI auto-loads `.env` files from your project root. Create a `.env` or `.env.local` file: ```bash title=".env.local" BETTER_I18N_API_KEY=bi_pub_your_api_key_here ``` You can get your Content API key from the [dashboard](https://dash.better-i18n.com) → Project Settings → API Keys. > [!NOTE] > Shell/CI environment variables always take precedence over `.env` files. ### 2. Run the generator ```bash npx better-i18n content:types ``` ```text ✓ Project: acme/landing ✓ Found 3 content model(s) ✓ Content types generated Output: src/content-types.ts Models: blog-posts, changelog, pricing-plans Usage: import type { BlogPosts } from "./content-types"; const { data } = await client.from("blog-posts").execute(); ``` ## Generated Output The generated file includes typed interfaces for each content model's custom fields, plus convenience type aliases: ```typescript title="src/content-types.ts" /** * Auto-generated by @better-i18n/cli — do not edit manually. */ import type { ContentEntry, ContentEntryListItem } from "@better-i18n/sdk"; /** Blog Posts — Localized blog posts for the landing site */ export interface BlogPostsFields extends Record { author: RelationValue | null; category: RelationValue | null; featured: string | null; read_time: string; } export type BlogPosts = ContentEntry; export type BlogPostsListItem = ContentEntryListItem; /** Content model: pricing-plans */ export interface PricingPlansFields extends Record { plan_id: "free" | "pro" | "enterprise"; name: string; description: string; monthly_prices: string | null; // ... more fields } export type PricingPlans = ContentEntry; export type PricingPlansListItem = ContentEntryListItem; /** All content model slugs. */ export type ContentModelSlug = "blog-posts" | "pricing-plans" | "changelog"; /** Map from model slug to its custom fields type. */ export interface ContentTypeMap { "blog-posts": BlogPostsFields; "pricing-plans": PricingPlansFields; "changelog": ChangelogFields; } ``` ## Using Generated Types ### With the SDK client ```typescript import { createClient } from "@better-i18n/sdk"; import type { BlogPostsFields, BlogPostsListItem } from "./content-types"; const client = createClient({ projectId: "acme/landing", apiKey: process.env.BETTER_I18N_API_KEY!, }); // Typed list query const { data } = await client .from("blog-posts") .eq("featured", "true") .execute(); // data is BlogPostsListItem[] — fully typed! ``` ### With getEntries (legacy) ```typescript const { items } = await client.getEntries("blog-posts", { status: "published", language: "en", }); // items[0].author — typed as RelationValue | null // items[0].read_time — typed as string ``` ## Field Type Mapping The generator maps content model field types to TypeScript: | CMS Field Type | TypeScript Type | Example | |---|---|---| | `text`, `textarea`, `richtext` | `string \| null` | `author: string \| null` | | `number` | `string \| null` | `read_time: string \| null` | | `boolean` | `string \| null` | `featured: string \| null` | | `date`, `datetime` | `string \| null` | `release_date: string \| null` | | `enum` | Literal union | `status: "active" \| "draft" \| null` | | `media` | `string \| null` | `cover: string \| null` | | `relation` | `RelationValue \| null` | `author: RelationValue \| null` | Required fields omit `| null` from the type. ## .env Auto-Loading The CLI automatically reads environment variables from `.env` files at startup: | File | Priority | Use case | |------|----------|----------| | Shell/CI vars | Highest | `export BETTER_I18N_API_KEY=xxx` | | `.env.local` | High | Local overrides (git-ignored) | | `.env` | Normal | Shared defaults | Variables already set in the shell are never overridden. ## CI/CD Integration Add type generation to your CI pipeline: ```yaml title=".github/workflows/types.yml" - name: Generate content types run: npx better-i18n content:types --output src/content-types.ts env: BETTER_I18N_API_KEY: ${{ secrets.BETTER_I18N_API_KEY }} - name: Check for uncommitted changes run: git diff --exit-code src/content-types.ts ``` Or add it as a package.json script: ```json title="package.json" { "scripts": { "types:content": "better-i18n content:types" } } ``` --- # Admin SDK Source: https://help.better-i18n.com/hi/docs/admin `@better-i18n/admin` is a typed, server-side SDK that gives you programmatic access to the full Better i18n platform. Use it in your API routes, scripts, CI/CD pipelines, or admin dashboards. It provides the same capabilities as the MCP server and CLI, but as a TypeScript library you can import directly. ## What you get - **Projects** — List and inspect your projects - **Keys** — Create, update, delete, and search translation keys - **Translations** — Read, write, and publish translations - **Content** — Full CRUD for content models, fields, and entries - **Analytics** — View counts, language breakdown, country stats, time series - **Sync** — Monitor and manage GitHub sync jobs - **Languages** — Add, update, and archive target languages ## When to use Admin SDK vs other tools | Tool | Best for | |------|----------| | **Admin SDK** | Server-side automation, admin dashboards, custom integrations | | **MCP Server** | AI agents (Claude, Cursor, Copilot) | | **CLI** | CI/CD pipelines, terminal workflows | | **Content SDK** | Client-side content fetching + view tracking | ## Quick example ```ts import { createAdminClient } from '@better-i18n/admin' const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY, projectId: 'nomadvibe/packervibe' }) // List translation keys const keys = await admin.keys.list({ search: 'auth' }) // Get content analytics with full breakdown const stats = await admin.analytics.stats('blog-posts', { period: '30d' }) console.log(stats.viewsByLanguage) // [{ language: 'en', views: 6 }, { language: 'de', views: 1 }, ...] // Publish translations to CDN await admin.translations.publish() ``` ## Architecture ``` Your server / script / CI │ ▼ @better-i18n/admin │ ├── tRPC ──► api.better-i18n.com (keys, translations, content, sync) │ └── REST ──► content.better-i18n.com/v1/analytics (views, stats) ``` All operations use your project API key (`bi-` prefix), which stays server-side. Client-side `bi_pub_` keys cannot access admin endpoints. --- # Languages Source: https://help.better-i18n.com/hi/docs/admin/languages `admin.languages` manages the target languages of the project the client is scoped to. The source language is a project setting, changed under Settings, CDN Delivery, not through this namespace. ## Add languages ```ts await admin.languages.add({ languages: [ { languageCode: "en-gb" }, { languageCode: "en-ca" }, { languageCode: "en-au", status: "draft" }, ], }); ``` | Field | Required | Description | | --- | --- | --- | | `languageCode` | yes | BCP 47 code, 2 to 10 characters. Must exist in the language table | | `status` | no | `"active"` (default) or `"draft"` | `active` languages are published to the CDN. `draft` languages are visible in the editor and left out of delivery, which is how you stage a locale before announcing it. Up to 50 languages per call. The parameter also accepts a JSON string, for agents that serialize arrays that way: ```ts await admin.languages.add({ languages: '[{"languageCode":"fr"}]' }); ``` ## Update status ```ts await admin.languages.update({ updates: [{ languageCode: "en-au", status: "active" }], }); ``` | Status | Effect | | --- | --- | | `active` | Published to the CDN | | `draft` | Visible in the editor, not deployed | | `archived` | Hidden from the editor and the CDN, translations kept | ## Archive languages ```ts await admin.languages.delete({ languageCodes: ["en-au"] }); ``` Despite the name this archives rather than destroys: the status moves to `archived` and every translation is preserved. Re-adding the language brings its existing content back. ## Regional locales Regional codes are first-class. A project can run `en-us` as its source with `en-gb`, `en-ca` and `en-au` as targets, and each locale gets its own file at `//translations.json`. ```ts await admin.languages.add({ languages: [{ languageCode: "en-gb" }, { languageCode: "en-ca" }, { languageCode: "en-au" }], }); ``` > [!WARNING] > Codes are stored lowercase and matched exactly. Write `en-ca`, not `en-CA`. The SDK normalises what you pass, but anything hand-rolling HTTP calls has to send the lowercase form. > [!INFO] > After changing your project's source language, update the locale your SDK asks for as well. A locale with no file returns an empty JSON object rather than an error, so a stale config shows up as missing strings instead of a clear failure. ## Errors An unsupported language code returns a 400 naming the code. If you get one, check it against the language list in the dashboard, since the code has to exist in the platform's language table before a project can target it. --- # Quick Start Source: https://help.better-i18n.com/hi/docs/admin/quick-start ## Install ```bash npm install @better-i18n/admin ``` ## Create a client ```ts import { createAdminClient } from '@better-i18n/admin' const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY, projectId: 'nomadvibe/packervibe' // Dashboard → Settings → Project ID }) ``` > [!NOTE] > The `projectId` uses slug format (`org/project`), the same value shown in your dashboard under **Settings → General → Project ID**. All SDK calls are automatically scoped to this project. ## API key Use the same API key you use for the MCP server or CLI. This is a **server-side secret key** (`bi-` prefix) — never expose it in client-side code. Find it in your dashboard: **Settings → API Keys → Create Key**. > [!WARNING] > Public keys (`bi_pub_` prefix) cannot access admin endpoints. They are designed for client-side content fetching only. ## Example: Next.js API route ```ts title="app/api/analytics/route.ts" import { createAdminClient } from '@better-i18n/admin' const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY!, projectId: process.env.BETTER_I18N_PROJECT_ID! }) export async function GET() { const views = await admin.analytics.views('blog-posts') return Response.json(views) } ``` ## Example: Express middleware ```ts title="server.ts" import { createAdminClient } from '@better-i18n/admin' import express from 'express' const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY!, projectId: 'nomadvibe/packervibe' }) const app = express() app.get('/api/popular-posts', async (req, res) => { const stats = await admin.analytics.stats('blog-posts', { period: '7d' }) const popular = stats.viewsByEntry.slice(0, 5) res.json(popular) }) ``` ## Available namespaces | Namespace | Methods | |-----------|---------| | `admin.projects` | `list()`, `get()` | | `admin.keys` | `list()`, `create()`, `update()`, `delete()` | | `admin.translations` | `get()`, `set()`, `publish()`, `context()`, `pendingChanges()` | | `admin.content.models` | `list()`, `get()`, `create()`, `update()`, `delete()` | | `admin.content.fields` | `add()`, `update()`, `remove()`, `reorder()` | | `admin.content.entries` | `list()`, `get()`, `create()`, `update()`, `publish()`, `delete()`, `duplicate()`, `bulkCreate()`, `bulkUpdate()`, `bulkPublish()` | | `admin.analytics` | `views()`, `stats()` | | `admin.sync` | `list()`, `get()`, `cancel()` | | `admin.languages` | `add()`, `update()`, `delete()` | --- # Response Shapes Source: https://help.better-i18n.com/hi/docs/admin/responses Read endpoints answer in a **compact** shape: short field names, and namespaces stored once per page instead of once per key. The same endpoints back the MCP servers, where the abbreviation cuts token use by roughly half. The Admin SDK returns them unchanged rather than maintaining a second wire format, so `keys.list()` gives you `k`, not `keys`. > [!WARNING] > This is the part people trip over. A response is not `{ keys: [...] }`. Read the table for the endpoint you are calling, or hover the return type in your editor, which carries the same field docs. ## keys.list() ```ts const res = await admin.keys.list({ limit: 50 }); for (const key of res.k) { console.log(res.nss[key.ns], key.k); // "common", "auth.login.title" } ``` | Field | Type | Meaning | | --- | --- | --- | | `tot` | number | Total matching keys, before pagination | | `ret` | number | Keys returned on this page | | `pg` | number | Current page, 1 indexed | | `lim` | number | Page size | | `has_more` | boolean | More pages exist, increment `pg` | | `nss` | string[] | Namespace lookup table | | `k` | array | The keys, see below | | `note` | string? | Hint, for example a large-project warning | Each entry in `k`: | Field | Type | Meaning | | --- | --- | --- | | `k` | string | Key name | | `ns` | number | **Index into `nss`**, not a namespace string | | `id` | string? | Key UUID, when `"id"` is in `fields` | | `src` | string? | Source text, when `"sourceText"` is in `fields` | | `tl` | string[]? | Language codes that have a translation | | `tlc` | number? | Count of translated languages, cheaper than `tl` | | `tr` | object? | Translations, when `"translations"` is in `fields` | | `p` | true? | Phantom key, a legacy duplicate worth deleting | `fields` defaults to `["id", "sourceText"]`. Ask for `translations` only when you need the text, since it dominates the response size. ## translations.get() | Field | Type | Meaning | | --- | --- | --- | | `prj` | string | Project slug | | `sl` | string | Source language code | | `ret` / `tot` | number | Returned, and total before pagination | | `has_more` | boolean | More results exist | | `keys` | array | `{ id, k, ns?, src, tr? }` per key | | `srch` / `lng` / `st` | — | Echo of the search, languages and status filters | | `nsd` | object? | Namespace descriptions | | `hint` | string? | Set when a filter was ignored, read it | Pass `compact: true` to get counts instead of text: each key becomes `{ id, k, ns?, tc }` where `tc` is the translation count. ## translations.pendingChanges() | Field | Type | Meaning | | --- | --- | --- | | `has_chg` | boolean | Anything waiting to publish | | `sum` | object | `{ tr, del_k, lng_chg, tot }` counts by kind | | `by_lng` | object | Pending counts per language | | `del_k` | array | Keys deleted but not yet published | | `pub_dst` | string | Where a publish would go | | `no_pub_rsn` | string? | Why publishing is currently blocked | ## sync.list() and sync.get() `list()` returns `{ prj, tot, sy: [...] }`. Each job: | Field | Meaning | | --- | --- | | `id` | Sync job ID | | `tp` | Job type | | `st` | Status | | `st_at` / `cp_at` | Started at, completed at | | `err_msg` | Failure reason | | `trig_by` | What triggered it | | `meta` | `{ kp, tf?, pf? }` keys processed, target and pushed files | `get()` adds `log` and `aff_k`, the affected keys as `{ k, act }` pairs. ## Write endpoints Writes answer with a receipt rather than the object you sent: | Method | Shape | | --- | --- | | `keys.create()` | `{ ok, cnt, new, ren, dup, k: [{ k, id, tr }], skip?, warn?, blocked?, hint? }` | | `keys.update()` | `{ ok, cnt, upd: [{ id?, k, lng, src }], errors?, hint? }` | | `keys.delete()` | `{ ok, cnt, mk: [{ id, k, ns }], skip?, hint? }` | | `translations.set()` | `{ ok, cnt, wrote, upd: [{ id, k, lng }], errors?, hint? }` | `cnt` is what the call touched, `errors` is per item, so a partial success is visible instead of being reported as a failure. > [!INFO] > `languages.*` and `translations.publish()` answer in full field names rather than the compact form. They were added later and were never on the token budget the read endpoints were shaped around. ## Always read `hint` and `warn` Several endpoints return a `hint` when a filter was silently ignored, or a `warn` when a write collided with something in another namespace. They are the only signal that a call did less than you asked. ```ts const res = await admin.keys.create({ k: [{ n: "cta.title", v: "Get started" }] }); if (res.warn) console.warn(res.warn); ``` --- # Sync Source: https://help.better-i18n.com/hi/docs/admin/sync `admin.sync` reads the job queue behind imports, publishes and CDN uploads. It does not start jobs. Those are triggered by a publish, a GitHub webhook, or a CLI sync. ## List jobs ```ts const res = await admin.sync.list({ limit: 10, status: "failed" }); for (const job of res.sy) { console.log(job.id, job.tp, job.st, job.err_msg ?? ""); } ``` | Option | Description | | --- | --- | | `limit` | How many jobs to return | | `status` | `pending`, `in_progress`, `completed`, `failed`, `cancelled` | | `type` | Filter by job type | See [Response Shapes](/admin/responses) for what each abbreviated field means. ## Get one job, optionally waiting for it ```ts const job = await admin.sync.get({ syncId, waitMs: 15000 }); ``` `waitMs` blocks until the job reaches a terminal state (`completed`, `failed` or `cancelled`) or the timeout elapses, in which case you get the latest snapshot. Omit it, or pass `0`, for a plain non-blocking read. The ceiling is 25000 ms. That is a deliberate margin under the 30 second Cloudflare Worker wall-time limit, leaving room for HTTP overhead and the final database read. This is the difference between polling and not polling after a publish: ```ts await admin.translations.publish(); const [latest] = (await admin.sync.list({ limit: 1 })).sy; const done = await admin.sync.get({ syncId: latest.id, waitMs: 25000 }); if (done.st === "failed") throw new Error(done.err_msg ?? "sync failed"); ``` `get()` also returns `log` and `aff_k`, the affected keys as `{ k, act }` pairs, which is usually enough to see what a job actually changed. ## Cancel a job ```ts await admin.sync.cancel({ syncId }); ``` Answers `{ id, can, prev, rsn }`: whether it was cancelled, the status it held beforehand, and the reason. A job already in a terminal state cannot be cancelled, and `rsn` tells you that rather than throwing. --- # Authentication Source: https://help.better-i18n.com/hi/docs/admin/authentication ## Key types | Key prefix | Type | Use case | Admin SDK | |-----------|------|----------|-----------| | `bi-` | Secret | Server-side: admin SDK, MCP, CLI | Supported | | `bi_pub_` | Public | Client-side: content fetching, view tracking | Rejected (403) | ## Security model The Admin SDK uses your project API key to authenticate against the platform API. This key: - Is scoped to your organization - Should only be used server-side (API routes, scripts, CI) - Must never be exposed in client bundles or frontend code - Is the same key used by the MCP server and CLI ## Environment setup ```bash title=".env" BETTER_I18N_API_KEY=bi-your-secret-key-here BETTER_I18N_PROJECT_ID=nomadvibe/packervibe ``` ```ts const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY!, projectId: process.env.BETTER_I18N_PROJECT_ID! }) ``` ## Custom API URL For self-hosted or development environments: ```ts const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY!, projectId: 'nomadvibe/packervibe', apiUrl: 'http://localhost:8787' // Custom API endpoint }) ``` --- # Projects Source: https://help.better-i18n.com/hi/docs/admin/projects ## List all projects ```ts const projects = await admin.projects.list() ``` Returns all projects accessible by your API key. ## Get current project ```ts const project = await admin.projects.get() ``` Returns details for the project set in `createAdminClient({ projectId })`. --- # Keys Source: https://help.better-i18n.com/hi/docs/admin/keys ## List keys ```ts const keys = await admin.keys.list() const filtered = await admin.keys.list({ search: 'auth.login' }) ``` ## Create keys ```ts await admin.keys.create({ k: [ { n: 'auth.login.title', v: 'Sign In' }, { n: 'auth.login.button', v: 'Continue' } ] }) ``` ## Update translations ```ts await admin.keys.update({ t: [ { id: 'key-uuid', l: 'de', t: 'Anmelden' } ] }) ``` ## Delete keys ```ts await admin.keys.delete({ keyIds: ['key-uuid-1', 'key-uuid-2'] }) ``` > [!WARNING] > Always call `list()` before `create()` to check for existing keys. Creating duplicate keys is a common agent mistake — see [MCP safety rules](/mcp/tool-reference). --- # Translations Source: https://help.better-i18n.com/hi/docs/admin/translations ## Get translations ```ts const translations = await admin.translations.get() const filtered = await admin.translations.get({ namespaces: ['common'] }) ``` ## Set translations (bulk) ```ts await admin.translations.set({ t: [ { id: 'key-uuid', t: { de: 'Anmelden', fr: 'Connexion' } } ] }) ``` ## Publish to CDN ```ts await admin.translations.publish() ``` ## Get translation context Retrieves glossary, instructions, and preferences for AI-assisted translation: ```ts const context = await admin.translations.context() ``` ## Check pending changes ```ts const pending = await admin.translations.pendingChanges() ``` --- # Content Source: https://help.better-i18n.com/hi/docs/admin/content The content namespace provides full CRUD for the Content CMS — models, fields, and entries. ## Models ```ts const models = await admin.content.models.list() const blog = await admin.content.models.get({ modelSlug: 'blog-posts' }) await admin.content.models.create({ slug: 'faq', displayName: 'FAQs', kind: 'collection' }) ``` ## Fields ```ts await admin.content.fields.add({ modelSlug: 'blog-posts', name: 'author', displayName: 'Author', type: 'short_text', required: true }) ``` ## Entries ```ts // List const entries = await admin.content.entries.list({ modelSlug: 'blog-posts' }) // Get const entry = await admin.content.entries.get({ modelSlug: 'blog-posts', entrySlug: 'my-first-post' }) // Create with translations await admin.content.entries.create({ modelSlug: 'blog-posts', slug: 'new-post', title: 'My New Post', body: 'Content here...', translations: { de: { title: 'Mein neuer Beitrag', body: 'Inhalt hier...' } } }) // Bulk operations await admin.content.entries.bulkUpdate({ ... }) await admin.content.entries.bulkPublish({ entryIds: ['id1', 'id2'] }) ``` --- # Analytics Source: https://help.better-i18n.com/hi/docs/admin/analytics The analytics namespace lets you read back the view data tracked by `@better-i18n/content` SDK's `useTrackView()` hook. ## View counts Get aggregate view counts per content entry: ```ts const views = await admin.analytics.views('blog-posts') // { // views: { // "better-auth-localization-guide": 42, // "digital-nomad-guide": 128 // }, // period: "30d", // cachedAt: "2026-05-16T15:23:10.514Z" // } ``` ### Single entry ```ts const entry = await admin.analytics.views('blog-posts', 'digital-nomad-guide') // { views: 128, period: "30d", cachedAt: "..." } ``` ### With period ```ts const weekly = await admin.analytics.views('blog-posts', { period: '7d' }) const daily = await admin.analytics.views('blog-posts', 'my-post', { period: '24h' }) ``` Available periods: `24h`, `7d`, `30d` (default), `90d`. ## Full stats breakdown Get a dashboard-grade analytics breakdown with 5 parallel queries: ```ts const stats = await admin.analytics.stats('blog-posts', { period: '30d' }) ``` Response: ```json { "overview": { "totalViews": 256, "uniqueEntries": 12 }, "viewsByEntry": [ { "slug": "digital-nomad-guide", "views": 128 }, { "slug": "better-auth-localization-guide", "views": 42 } ], "viewsByLanguage": [ { "language": "en", "views": 180 }, { "language": "de", "views": 40 }, { "language": "tr", "views": 36 } ], "viewsByCountry": [ { "country": "US", "views": 120 }, { "country": "DE", "views": 40 }, { "country": "TR", "views": 36 } ], "viewsOverTime": [ { "timestamp": "2026-05-01 00:00:00", "views": 8 }, { "timestamp": "2026-05-02 00:00:00", "views": 12 } ], "period": "30d", "cachedAt": "2026-05-16T15:23:13.249Z" } ``` ### Stats for a single entry ```ts const entryStats = await admin.analytics.stats('blog-posts', { period: '7d', entrySlug: 'digital-nomad-guide' }) ``` ## Time series granularity | Period | Bucket size | |--------|------------| | `24h` | 1 hour | | `7d` | 1 day | | `30d` | 1 day | | `90d` | 1 day | ## Caching All analytics responses are cached in KV with period-dependent TTL: | Period | Cache TTL | |--------|----------| | `24h` | 2 minutes | | `7d` | 5 minutes | | `30d` | 10 minutes | | `90d` | 10 minutes | ## Use case: popular posts widget ```ts title="app/api/popular/route.ts" import { createAdminClient } from '@better-i18n/admin' const admin = createAdminClient({ apiKey: process.env.BETTER_I18N_API_KEY!, projectId: 'nomadvibe/packervibe' }) export async function GET() { const stats = await admin.analytics.stats('blog-posts', { period: '7d' }) const popular = stats.viewsByEntry.slice(0, 5).map(entry => ({ slug: entry.slug, views: entry.views, })) return Response.json(popular, { headers: { 'Cache-Control': 'public, max-age=300' } }) } ``` --- # Admin SDK API Reference Source: https://help.better-i18n.com/hi/docs/admin/api-reference ## createAdminClient(config) ```ts import { createAdminClient } from '@better-i18n/admin' const admin = createAdminClient({ apiKey: string, // Required — server-side API key (bi- prefix) projectId: string, // Required — "org/project" slug format apiUrl?: string, // Optional — default: "https://api.better-i18n.com" debug?: boolean, // Optional — enable request logging fetch?: typeof fetch, // Optional — custom fetch implementation }) ``` ## admin.projects | Method | Returns | |--------|---------| | `list()` | All projects in org | | `get()` | Current project details | ## admin.keys | Method | Input | Returns | |--------|-------|---------| | `list(opts?)` | `{ search?, namespace? }` | Key list | | `create(input)` | `{ k: [{ n, v, ns? }] }` | Created keys | | `update(input)` | `{ t: [{ id, l, t }] }` | Updated translations | | `delete(input)` | `{ keyIds: string[] }` | Deletion result | ## admin.translations | Method | Input | Returns | |--------|-------|---------| | `get(opts?)` | `{ namespaces?, keys? }` | Translation data | | `set(input)` | `{ t: [{ id, t: { lang: text } }] }` | Set result | | `publish(opts?)` | `{ translations? }` | Publish result | | `context(opts?)` | `{ keyIds? }` | Glossary + instructions | | `pendingChanges()` | — | Pending changes | ## admin.content.models | Method | Input | Returns | |--------|-------|---------| | `list(opts?)` | — | Model list | | `get(input)` | `{ modelSlug }` | Model detail | | `create(input)` | `{ slug, displayName, kind }` | Created model | | `update(input)` | `{ modelSlug, ... }` | Updated model | | `delete(input)` | `{ modelSlug }` | Deletion result | ## admin.content.entries | Method | Input | |--------|-------| | `list(opts?)` | `{ modelSlug, language?, search? }` | | `get(input)` | `{ modelSlug, entrySlug }` | | `create(input)` | `{ modelSlug, slug, title, body?, translations? }` | | `update(input)` | `{ modelSlug, entrySlug, ... }` | | `publish(input)` | `{ modelSlug, entrySlug }` | | `delete(input)` | `{ modelSlug, entrySlug }` | | `duplicate(input)` | `{ modelSlug, entrySlug }` | | `bulkCreate(input)` | `{ modelSlug, entries: [...] }` | | `bulkUpdate(input)` | `{ entries: [...] }` | | `bulkPublish(input)` | `{ entryIds: [...] }` | ## admin.analytics | Method | Input | Returns | |--------|-------|---------| | `views(model, entry?, opts?)` | `model: string`, `opts: { period? }` | `{ views, period, cachedAt }` | | `stats(model, opts?)` | `opts: { period?, entrySlug? }` | `{ overview, viewsByEntry, viewsByLanguage, viewsByCountry, viewsOverTime }` | ## admin.sync | Method | Input | |--------|-------| | `list(opts?)` | `{ limit? }` | | `get(input)` | `{ syncId }` | | `cancel(input)` | `{ syncId }` | ## admin.languages | Method | Input | |--------|-------| | `add(input)` | `{ languages: [{ code }] }` | | `update(input)` | `{ updates: [{ languageCode, status }] }` | | `delete(input)` | `{ languageCodes: [...] }` | --- # API Source: https://help.better-i18n.com/hi/docs/api Better i18n provides two main APIs: 1. **MCP Tools** - For AI assistants (Claude, Cursor, etc.) 2. **REST API** - For direct integrations ## MCP Tools The MCP (Model Context Protocol) server exposes these tools for AI-powered translation management: | Tool | Description | |------|-------------| | [listProjects](/api/list-projects) | List all projects you have access to | | [getProject](/api/get-project) | Get project details including languages and namespaces | | [addLanguage](/api/add-language) | Add a new target language to a project | | [listKeys](/api/list-keys) | Get translation keys with search and filters | | [createKeys](/api/create-keys) | Create new translation keys | | [updateKeys](/api/update-keys) | Update translations for existing keys | | [deleteKeys](/api/delete-keys) | Soft-delete translation keys | | [getSyncs](/api/get-syncs) | List recent sync operations | | [getSync](/api/get-sync) | Get details of a specific sync | ## Authentication All API calls require an API key. Get yours from the [dashboard](https://dash.better-i18n.com/settings/api-keys). ```bash # For MCP Server export BETTER_I18N_API_KEY="your-api-key" # For REST API curl -H "Authorization: Bearer your-api-key" \ https://dash.better-i18n.com/api/... ``` ## Project Identifier Most tools require a `project` parameter in `org/project` format: ```json { "project": "my-org/my-project" } ``` You can find this in your project's `i18n.ts` config file. --- # listProjects Source: https://help.better-i18n.com/hi/docs/api/list-projects List all projects you have access to. Call this first to discover available projects before using other tools. ## Parameters This tool takes no parameters. ## Example ```json {} ``` ## Response Returns an array of projects with their details: ```json { "projects": [ { "id": "proj_abc123", "name": "My App", "slug": "my-app", "organization": { "id": "org_xyz", "name": "My Org", "slug": "my-org" }, "sourceLanguage": "en", "targetLanguages": ["tr", "de", "fr"], "keyCount": 150 } ] } ``` ## Usage Use this tool first to discover your projects, then use `getProject` for detailed information about a specific project. --- # proposeLanguages Source: https://help.better-i18n.com/hi/docs/api/propose-languages Add one or more target languages to the project. Use ISO 639-1 codes (e.g. `fr`, `ja`, `de`) or BCP 47 locale codes (e.g. `zh-Hans`, `pt-BR`). Already-existing languages are silently skipped. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `languages` | object[] | Yes | Languages to add. Each item: `{ languageCode, status? }` | ### `languages[]` | Field | Type | Required | Description | |-------|------|----------|-------------| | `languageCode` | string | Yes | ISO 639-1 code (e.g. `fr`, `de`, `ja`) or BCP 47 locale (e.g. `zh-Hans`, `pt-BR`) | | `status` | `active` \| `draft` | No | `active` publishes the language to the CDN (default); `draft` keeps it visible but undeployed | ## Example ```json { "project": "my-org/my-app", "languages": [ { "languageCode": "ja" }, { "languageCode": "pt-BR", "status": "draft" } ] } ``` --- # getProject Source: https://help.better-i18n.com/hi/docs/api/get-project Get project details including namespaces, languages, key count, and translation coverage. Use this after `listProjects` to understand a specific project's structure. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | ## Example ```json { "project": "my-org/my-app" } ``` ## Response Returns detailed project information: ```json { "id": "proj_abc123", "name": "My App", "slug": "my-app", "sourceLanguage": "en", "targetLanguages": ["tr", "de", "fr"], "keyCount": 150, "namespaces": [ { "name": "common", "keyCount": 45, "description": "Common UI elements", "context": { "team": "frontend", "domain": "ui", "aiPrompt": "Keep translations concise", "tags": ["ui", "buttons"] } }, { "name": "auth", "keyCount": 25, "description": "Authentication flows" } ], "coverage": { "tr": 95.5, "de": 80.0, "fr": 60.0 } } ``` ## Namespace Context Namespaces include rich metadata that helps with organization and AI translation: - **description**: What this namespace is about - **team**: Team owning this namespace - **domain**: Business domain (e.g., 'auth', 'billing') - **aiPrompt**: Custom instructions for AI translations - **tags**: Tags for categorization --- # addLanguage Source: https://help.better-i18n.com/hi/docs/api/add-language Add a new target language to the project. Use ISO 639-1 language codes. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `languageCode` | string | Yes | ISO 639-1 language code (e.g., 'fr', 'ja', 'de') | ## Example ```json { "project": "my-org/my-app", "languageCode": "ja" } ``` ## Response ```json { "success": true, "message": "Language 'ja' added successfully", "languageCode": "ja", "project": "my-org/my-app", "alreadyExists": false } ``` ## Common Language Codes | Code | Language | |------|----------| | `en` | English | | `tr` | Turkish | | `de` | German | | `fr` | French | | `es` | Spanish | | `ja` | Japanese | | `ko` | Korean | | `zh` | Chinese | | `ar` | Arabic | | `pt` | Portuguese | ## Notes - If the language already exists, `alreadyExists` will be `true` - Adding a language creates empty translations for all existing keys - Use AI translation to populate the new language quickly --- # listKeys Source: https://help.better-i18n.com/hi/docs/api/list-keys Get all translation keys with their ID, source text, and translations. Supports full-text search and filtering. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `search` | string | No | Text to search for (case-insensitive) | | `languages` | string[] | No | Language codes to search in and return | | `namespaces` | string[] | No | Filter by namespace(s) | | `keys` | string[] | No | Fetch specific keys by exact name | | `status` | string | No | Filter by status: `missing`, `draft`, `published`, `all` | ## Examples ### Search in source text ```json { "project": "my-org/my-app", "search": "login" } ``` ### Search in Turkish translations ```json { "project": "my-org/my-app", "search": "Giriş", "languages": ["tr"] } ``` ### Get missing Turkish translations ```json { "project": "my-org/my-app", "languages": ["tr"], "status": "missing" } ``` ### Get specific keys ```json { "project": "my-org/my-app", "keys": ["auth.login.title", "auth.login.button"] } ``` ## Response ```json { "keys": [ { "id": "key_abc123", "name": "auth.login.title", "namespace": "auth", "sourceText": "Sign in to your account", "translations": { "tr": "Hesabınıza giriş yapın", "de": "Melden Sie sich an" } } ], "namespaceDetails": { "auth": { "name": "auth", "keyCount": 25, "description": "Authentication flows", "context": { "team": "auth-team", "domain": "auth" } } } } ``` ## Search Behavior - If `languages` is specified, searches in those languages - If `languages` is omitted, searches in source text - Search is case-insensitive and matches partial strings --- # createKeys Source: https://help.better-i18n.com/hi/docs/api/create-keys Create translation keys with source text and optional translations. Don't include the source language in translations - use `sourceText` instead. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `keys` | array | Yes | Array of keys to create | ### Key Object | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Key name (e.g., 'submit_button', 'nav.home') | | `namespace` | string | No | Namespace (default: 'default') | | `sourceText` | string | No | Source language text | | `translations` | object | No | Target translations as `{langCode: text}` | | `namespaceContext` | object | No | Context for the namespace | ### Namespace Context | Field | Type | Description | |-------|------|-------------| | `description` | string | What this namespace is about | | `team` | string | Team owning this namespace | | `domain` | string | Business domain (e.g., 'auth', 'billing') | | `aiPrompt` | string | Custom AI prompt for translations | | `tags` | string[] | Tags for categorization | ## Example ```json { "project": "my-org/my-app", "keys": [ { "name": "auth.login.title", "namespace": "auth", "sourceText": "Sign in to your account", "translations": { "tr": "Hesabınıza giriş yapın", "de": "Melden Sie sich bei Ihrem Konto an" }, "namespaceContext": { "description": "Authentication related strings", "team": "auth-team", "domain": "auth" } }, { "name": "auth.login.button", "namespace": "auth", "sourceText": "Sign in" } ] } ``` ## Response ```json { "success": true, "project": "my-org/my-app", "keysCreated": 2, "keys": [ { "id": "key_abc123", "name": "auth.login.title" }, { "id": "key_def456", "name": "auth.login.button" } ] } ``` ## Notes - Namespace context is applied once per namespace - If a key already exists, it will be skipped - Source text goes in `sourceText`, not in `translations` --- # updateKeys Source: https://help.better-i18n.com/hi/docs/api/update-keys Update translations. Each entry updates ONE language for ONE key. Set `isSource=true` to update the source text. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `translations` | array | Yes | Array of translation updates | ### Translation Object | Field | Type | Required | Description | |-------|------|----------|-------------| | `key` | string | Yes | Key name (e.g., 'submit_button') | | `language` | string | Yes | Language code (e.g., 'en', 'tr', 'de') | | `text` | string | Yes | Translation text | | `namespace` | string | No | Namespace (default: 'default') | | `isSource` | boolean | No | Set to `true` to update source text | | `status` | string | No | Translation status (e.g., 'published') | | `namespaceContext` | object | No | Context for the namespace | ## Examples ### Update a translation ```json { "project": "my-org/my-app", "translations": [ { "key": "auth.login.title", "language": "tr", "text": "Giriş yap" } ] } ``` ### Update source text ```json { "project": "my-org/my-app", "translations": [ { "key": "auth.login.title", "language": "en", "text": "Log in to your account", "isSource": true } ] } ``` ### Publish a translation ```json { "project": "my-org/my-app", "translations": [ { "key": "auth.login.title", "language": "tr", "text": "Hesabınıza giriş yapın", "status": "published" } ] } ``` ### Batch update ```json { "project": "my-org/my-app", "translations": [ { "key": "auth.login.title", "language": "tr", "text": "Giriş" }, { "key": "auth.login.button", "language": "tr", "text": "Giriş yap" }, { "key": "auth.login.button", "language": "de", "text": "Anmelden" } ] } ``` ## Response ```json { "success": true, "project": "my-org/my-app", "keysUpdated": 2, "updates": [ { "key": "auth.login.title", "language": "tr", "status": "updated" }, { "key": "auth.login.button", "language": "tr", "status": "updated" } ] } ``` --- # deleteKeys Source: https://help.better-i18n.com/hi/docs/api/delete-keys Soft-delete translation keys by UUID. Keys are marked for deletion and removed from CDN/GitHub on next publish. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `keyIds` | string[] | Yes | Array of key UUIDs to delete (max 100) | ## Example ```json { "project": "my-org/my-app", "keyIds": [ "550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8" ] } ``` ## Response ```json { "success": true, "project": "my-org/my-app", "markedCount": 2, "marked": [ "550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8" ], "skipped": [] } ``` ## Getting Key UUIDs Use `listKeys` to get key UUIDs: ```json { "project": "my-org/my-app", "keys": ["auth.old_key"] } ``` Response includes `id` field with the UUID. ## Notes - This is a soft delete - keys are marked with `deletedAt` timestamp - Keys are permanently removed on the next publish/sync - Maximum 100 keys can be deleted in a single request - Already deleted keys are skipped (appear in `skipped` array) --- # getSyncs Source: https://help.better-i18n.com/hi/docs/api/get-syncs List recent sync operations for a project. Returns sync jobs with status, type, and timing. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `project` | string | Yes | Project identifier in `org/project` format | | `limit` | number | No | Maximum results (1-50, default: 10) | | `status` | string | No | Filter by status | | `type` | string | No | Filter by sync type | ## Response ```json { "syncs": [ { "id": "sync_abc123", "type": "cdn_upload", "status": "completed", "keysAffected": 15 } ] } ``` --- # getSync Source: https://help.better-i18n.com/hi/docs/api/get-sync Get details about a specific sync operation including logs and affected keys. Use `syncId` from `getSyncs` response. ## Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `syncId` | string | Yes | Sync operation ID | ## Example ```json { "syncId": "sync_abc123" } ``` ## Response ```json { "id": "sync_abc123", "type": "cdn_upload", "status": "completed", "keysAffected": 15, "affectedKeys": [ { "id": "key_abc", "name": "auth.login.title", "action": "updated" } ] } ``` --- # OAuth 2.0 Source: https://help.better-i18n.com/hi/docs/oauth Better i18n supports **OAuth 2.0 Authorization Code** for third-party integrations. Server-side apps use a `client_secret` (confidential clients); browser-only apps may use PKCE (public clients). If you're building a platform, IDE plugin, or any app that needs to manage translations on behalf of your users, this is how you connect. ## How it works ``` Your App Better i18n ──────── ─────────── 1. Redirect user → Consent screen (org + scope selection) 2. Receive auth code ← Redirect back with ?code= 3. Exchange code → dash.better-i18n.com/api/auth/mcp/token for tokens ← access_token + refresh_token + grant_id 4. Mint installation → POST /api/oauth-client/installations/:grantId/tokens token ← bi_oat_... (1h, scoped) 5. Call resource APIs → GET /api/oauth-client/v1/projects/:id ← Scoped project data ``` ## Token model | Token | Lifetime | Purpose | |-------|----------|---------| | Authorization code | 10 min | One-time exchange | | Access token | 30 min | Mint installation tokens only | | Refresh token | 30 days | Renew access tokens | | Installation token (`bi_oat_...`) | 1 hour | All resource API calls | > [!WARNING] > All tokens are **secret — server-side only**. Never embed them in browser bundles, mobile binaries, or public repos. The `bi_oat_` prefix is distinct from `bi_pub_` (public Content API keys) so leaked credentials can be identified at a glance. ## Why two layers? The **access token** can mint installation tokens but cannot read resources. The **installation token** can read/write resources but cannot mint more tokens. This means: - A leaked installation token expires in ≤ 1 hour with no path to escalation - The user can revoke one partner-org pair without affecting others - Partners get fine-grained, per-organization, per-project scoping ## Base URLs Use the dashboard origin for browser-based OAuth, because the user signs in and approves consent there: ``` https://dash.better-i18n.com/api/auth/mcp/authorize https://dash.better-i18n.com/api/auth/mcp/token ``` Use the API origin for partner resource calls after you have an installation token: ``` https://api.better-i18n.com/api/oauth-client/... ``` ## Three channels, one token Once you have a `bi_oat_` token, you can use it across: | Channel | How | Use case | |---------|-----|----------| | **REST API** | `Authorization: Bearer bi_oat_...` | Backend automation | | **MCP Server** | `BETTER_I18N_API_KEY=bi_oat_...` | AI agents (Cursor, Claude Code) | | **CDN** | No auth needed | Runtime translation delivery | The same scope enforcement applies everywhere — a token without `translations:publish` can't publish via REST or MCP. ## Quick links - [Register your app](/oauth/register-app) — Get a `client_id` - [Authorization flow](/oauth/authorization-flow) — PKCE, consent, code exchange (server + SPA) - [Installation tokens](/oauth/installation-tokens) — Mint and cache `bi_oat_` keys - [Calling APIs](/oauth/calling-apis) — Use installation tokens with partner API endpoints - [Scopes](/oauth/scopes) — What each scope grants - [MCP integration](/oauth/mcp-integration) — Use your token with AI agents - [Error reference](/oauth/errors) — Status codes and error bodies - [Local development](/oauth/local-development) — Run the flow against `localhost:5173` --- # Register Your App Source: https://help.better-i18n.com/hi/docs/oauth/register-app ## How registration works OAuth applications are registered by the Better i18n team during partner onboarding. We create the application record in our database with your app's details and send you back a `client_id`. > [!NOTE] > **Self-service registration is coming soon.** For now, reach out to us at [partners@better-i18n.com](mailto:partners@better-i18n.com) or through your existing contact to get started. ## What we need from you | Field | Description | |-------|-------------| | **App name** | What users see on the consent screen | | **Icon URL** | Square PNG or SVG, ≥ 64×64 (optional but recommended) | | **Redirect URIs** | Where we send users after authorization. Byte-for-byte match required at exchange time. HTTPS required in production; `http://localhost` is allowed for development. Send the full list (prod + every dev port). | | **Application type** | `web` (server-side), `native` (desktop/mobile), or `spa` (browser-only). All types use PKCE; the field is informational only. | ## What you'll receive - A **`client_id`** — your app's unique identifier - A **`client_secret`** — a secret key your server uses during token exchange and refresh > [!WARNING] > **Store your `client_secret` securely — server-side only.** Treat it like a database password. Never embed it in browser bundles, mobile binaries, environment files committed to Git, or public repos. If compromised, contact us immediately — we'll rotate it and invalidate existing tokens. > [!NOTE] > **SPA or native apps** that cannot safely store a secret may request a PKCE-only public client instead. Contact us during onboarding to discuss your architecture. Server-side applications always use `client_secret`. ## What you'll need on your side - A redirect URI endpoint (e.g., `https://yourapp.com/oauth/callback`) - A backend to handle the callback and store `client_secret`, `refresh_token`, and `grant_id` securely - Your `client_secret` available as an environment variable (e.g., `BETTER_I18N_CLIENT_SECRET`) > [!NOTE] > **Local development**: Include the **dev callback for your app** in your redirect URIs (e.g., `http://localhost:3000/oauth/callback`). HTTP without TLS is allowed for `localhost` only. The Better i18n side you're calling against is `http://localhost:5173` if you're running our dashboard locally — see [Local development](/oauth/local-development). ## Dynamic Client Registration (advanced) If your platform programmatically provisions integrations, we support [RFC 7591 Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) at: ``` POST https://api.better-i18n.com/api/auth/mcp/register ``` This endpoint is rate-limited and requires prior arrangement. Contact us if you need automated registration. ## Next step Once you have a `client_id`, [start the authorization flow](/oauth/authorization-flow). --- # Authorization Flow Source: https://help.better-i18n.com/hi/docs/oauth/authorization-flow ## 1. Build the authorize URL Redirect the user to Better i18n's authorization endpoint: ``` https://dash.better-i18n.com/api/auth/mcp/authorize ?response_type=code &client_id= &redirect_uri= &scope=org:read+projects:read+keys:read+keys:write+translations:write &state= &code_challenge= &code_challenge_method=S256 ``` > [!NOTE] > Use the **narrowest scope set** you need. Destructive scopes (`translations:publish`, `projects:write`, `glossary:write`) require an explicit user click and show a yellow warning on the consent screen. ## 2. User consents The user sees our consent screen where they: - Pick one or more organizations they can administer - Toggle individual permissions on/off - Click **Authorize** After consenting, the user lands on a "Connection complete" screen with a **Continue to your app** button. When they click it, we redirect to your `redirect_uri` with `?code=...&state=...`. ## 3. Exchange code for tokens ```bash curl -X POST https://dash.better-i18n.com/api/auth/mcp/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=" \ --data-urlencode "redirect_uri=" \ --data-urlencode "client_id=" \ --data-urlencode "client_secret=" ``` > [!WARNING] > **`client_secret` is required** for all confidential (server-side) clients. The server rejects the exchange without it. If you're using a PKCE-only public client, send `code_verifier` instead — see the [SPA flow](#spa-only-pkce-flow) below. ### Response ```json { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 1800, "refresh_token": "rt_...", "grant_id": "grt_8f3c..." } ``` > [!WARNING] > **Persist the `grant_id`.** You need it for every installation token mint and every revoke. Store it alongside the `refresh_token` in your database. ## Full Node.js example ```ts title="routes/connect.ts" // Start the flow app.get("/connect/start", async (c) => { const state = crypto.randomUUID(); await kv.set(`oauth_state:${state}`, { userId: c.user.id }, { expirationTtl: 600, }); const url = new URL( "https://dash.better-i18n.com/api/auth/mcp/authorize", ); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", env.BETTER_I18N_CLIENT_ID); url.searchParams.set("redirect_uri", `${env.PUBLIC_URL}/connect/callback`); url.searchParams.set("scope", "org:read projects:read keys:write translations:write"); url.searchParams.set("state", state); return c.redirect(url.toString()); }); // Handle callback app.get("/connect/callback", async (c) => { const code = c.req.query("code"); const state = c.req.query("state"); const stored = await kv.get(`oauth_state:${state}`); if (!code || !stored) return c.text("Invalid state", 400); await kv.delete(`oauth_state:${state}`); const res = await fetch( "https://dash.better-i18n.com/api/auth/mcp/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: `${env.PUBLIC_URL}/connect/callback`, client_id: env.BETTER_I18N_CLIENT_ID, client_secret: env.BETTER_I18N_CLIENT_SECRET, }), }, ); const tok = await res.json(); await db.insert("connections", { userId: stored.userId, grantId: tok.grant_id, refreshToken: tok.refresh_token, }); return c.redirect("/settings?connected=better-i18n"); }); ``` ## SPA-only PKCE flow > [!NOTE] > This section applies only to **public clients** (type `spa` or `native`) that cannot store a `client_secret`. Public clients must be explicitly requested during partner onboarding — the default is a confidential `web` client with a secret. If your app has no server (browser-only SPA, Electron, etc.), run the entire exchange in the browser. PKCE makes this safe — the `code_verifier` never leaves the popup, and the access token is short-lived. ```ts title="lib/oauth.ts" const BASE_URL = import.meta.env.VITE_BETTER_I18N_URL ?? "https://dash.better-i18n.com"; const CLIENT_ID = import.meta.env.VITE_BETTER_I18N_CLIENT_ID; // 1. Generate PKCE pair, persist verifier across the popup round-trip async function buildAuthUrl(): Promise { const verifier = crypto.randomUUID() + crypto.randomUUID(); const challenge = await sha256Base64Url(verifier); const state = crypto.randomUUID(); localStorage.setItem("pkce", JSON.stringify({ verifier, state })); const params = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: `${window.location.origin}/oauth/callback`, scope: "org:read projects:read keys:read keys:write translations:write", state, code_challenge: challenge, code_challenge_method: "S256", }); return `${BASE_URL}/api/auth/mcp/authorize?${params}`; } // 2. Open the popup function startConnect() { const url = await buildAuthUrl(); window.open(url, "better-i18n-oauth", "width=520,height=720"); } // 3. Handle callback at /oauth/callback (popup page) async function handleCallback() { const params = new URLSearchParams(window.location.search); const code = params.get("code"); const state = params.get("state"); const stored = JSON.parse(localStorage.getItem("pkce") ?? "{}"); localStorage.removeItem("pkce"); if (!code || state !== stored.state) { window.opener?.postMessage({ type: "oauth-error", error: "state_mismatch" }, window.location.origin); return; } const res = await fetch(`${BASE_URL}/api/auth/mcp/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: `${window.location.origin}/oauth/callback`, client_id: CLIENT_ID, code_verifier: stored.verifier, }), }); if (!res.ok) { window.opener?.postMessage({ type: "oauth-error", error: "exchange_failed" }, window.location.origin); return; } const tokens = await res.json(); // Send tokens to the parent window (same-origin postMessage), then close window.opener?.postMessage({ type: "oauth-success", tokens }, window.location.origin); window.close(); } async function sha256Base64Url(input: string): Promise { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); return btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } ``` > [!WARNING] > **Pin `postMessage` to your own origin** — `window.opener.postMessage(data, window.location.origin)`. A wildcard target (`"*"`) leaks tokens to whatever site is currently in the opener tab. In the parent window, listen for the popup's message and ship the `refresh_token` + `grant_id` to your backend. Don't keep them in `localStorage` long-term; persist server-side and treat anything in the browser as ephemeral. ## Next step Now [mint an installation token](/oauth/installation-tokens) to start calling APIs. --- # Installation Tokens Source: https://help.better-i18n.com/hi/docs/oauth/installation-tokens The access token from the authorization flow **cannot read resources directly**. To call partner API endpoints, exchange it for a 1-hour installation token: ## Mint a token ```bash curl -X POST \ https://api.better-i18n.com/api/oauth-client/installations//tokens \ -H "Authorization: Bearer " ``` ### Response ```json { "installation_token": "bi_oat_d7ab12...", "token_type": "Bearer", "expires_at": "2026-04-30T12:00:00.000Z", "expires_in": 3600, "organization_id": "org_...", "project_ids": [], "scopes": ["org:read", "keys:read", "keys:write", "translations:write"] } ``` `project_ids: []` means **all projects in this organization**. A non-empty array restricts access to those specific projects. ## Caching strategy Mint a new token **only when the cached one is within ~60 seconds of expiring**: ```ts title="lib/better-i18n.ts" let cache: { token: string; expiresAt: number } | null = null; async function getInstallationToken( grantId: string, accessToken: string, ): Promise { if (cache && cache.expiresAt - Date.now() > 60_000) { return cache.token; } const res = await fetch( `https://api.better-i18n.com/api/oauth-client/installations/${grantId}/tokens`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}` }, }, ); const data = await res.json(); cache = { token: data.installation_token, expiresAt: new Date(data.expires_at).getTime(), }; return cache.token; } ``` > [!WARNING] > **Do not mint per request.** Every mint is logged in the audit trail and rate-limited. In practice you need 1-2 mints per hour. ## What's inside the token The installation token is a Better i18n API key (`apikey` table row) with embedded permissions: ```json { "type": "installation", "grantId": "grt_...", "organizationId": "org_...", "projectIds": [], "scopes": ["keys:read", "keys:write"] } ``` The middleware reads these permissions on every request — no additional lookup needed. ## Next step Use the installation token to [call resource APIs](/oauth/calling-apis). --- # Calling APIs Source: https://help.better-i18n.com/hi/docs/oauth/calling-apis Send the installation token as the Bearer token for partner API endpoints. ## Identity Use the OAuth access token to check who connected and which Better i18n account it belongs to: ```bash curl https://api.better-i18n.com/api/oauth-client/me \ -H "Authorization: Bearer " ``` ## Organizations and projects List organizations the user granted access to: ```bash curl https://api.better-i18n.com/api/oauth-client/organizations \ -H "Authorization: Bearer bi_oat_d7ab12..." \ ``` List projects in an organization: ```bash curl https://api.better-i18n.com/api/oauth-client/organizations//projects \ -H "Authorization: Bearer bi_oat_d7ab12..." ``` Create a CDN-backed project in the granted organization: ```bash curl -X POST https://api.better-i18n.com/api/oauth-client/v1/projects \ -H "Authorization: Bearer bi_oat_d7ab12..." \ -H "Content-Type: application/json" \ -d '{ "name": "Marketing site", "slug": "marketing-site", "sourceLanguage": "en" }' ``` Read project details: ```bash curl https://api.better-i18n.com/api/oauth-client/v1/projects/ \ -H "Authorization: Bearer bi_oat_d7ab12..." ``` Add a target language: ```bash curl -X POST https://api.better-i18n.com/api/oauth-client/v1/projects//languages \ -H "Authorization: Bearer bi_oat_d7ab12..." \ -H "Content-Type: application/json" \ -d '{ "code": "de" }' ``` ## Translation keys and values Dedicated REST endpoints for keys, translations, publishing, glossary, and Content CMS writes are in active partner rollout. Until they are available in your account, use the Better i18n MCP tools with the same `bi_oat_` token for translation key and value operations. See [MCP integration](/oauth/mcp-integration). ## Scope enforcement If your token is missing the required scope, you'll get: ```json { "error": "missing_scope", "required": "translations:publish" } ``` **Do not retry this.** The user needs to re-authorize with the wider scope. See [scopes](/oauth/scopes) for the full mapping. ## Identity endpoint `/api/oauth-client/me` accepts **either** the OAuth access token or a `bi_oat_` installation token in the `Authorization` header — it doesn't require any scope. All other resource endpoints (organizations, projects, keys, etc.) require an installation token. Use `/me` right after exchange to confirm the connection succeeded before minting an installation token. ## Handling revocation When a token is revoked, API calls return: ```json HTTP/1.1 410 Gone { "error": "grant_revoked" } ``` Treat this as a first-class state in your UI — show a "Reconnect" prompt, don't retry. ## Next step See the [scope reference](/oauth/scopes) for what each scope unlocks. --- # Scopes Source: https://help.better-i18n.com/hi/docs/oauth/scopes ## Scope reference | Scope | Default | What it opens | |-------|---------|---------------| | `org:read` | On | Organization metadata | | `projects:read` | On | List and get projects | | `projects:write` | **Off** | Create/modify project settings | | `keys:read` | On | List translation keys | | `keys:write` | On | Create, update, delete keys | | `translations:read` | On | Read translations | | `translations:write` | On | Write translations (drafts) | | `translations:publish` | **Off** | Publish to the public CDN | | `content:read` | On | Read Content CMS entries | | `content:write` | On | Write Content CMS entries | | `content:publish` | **Off** | Publish content entries | | `glossary:read` | On | Read glossary terms | | `glossary:write` | **Off** | Modify glossary terms | ## Default vs destructive scopes **Default-on** scopes are pre-checked on the consent screen. The user can uncheck any of them. **Default-off** scopes (marked **Off** above) require the user to explicitly opt in. They surface a yellow warning on the consent screen because they can modify production-facing content: - `translations:publish` — pushes to the public CDN - `content:publish` — publishes content entries - `projects:write` — changes project structure - `glossary:write` — rewrites brand terminology ## Endpoint → scope mapping | Method | Endpoint | Required scope | |--------|----------|---------------| | `GET` | `/api/oauth-client/me` | None (identity, access token) | | `GET` | `/api/oauth-client/organizations` | `org:read` | | `GET` | `/api/oauth-client/organizations/:orgId/projects` | `projects:read` | | `GET` | `/api/oauth-client/organizations/:orgSlug/projects/:projectSlug/models` | `content:read` | | `POST` | `/api/oauth-client/v1/projects` | `projects:write` | | `GET` | `/api/oauth-client/v1/projects/:projectId` | `projects:read` | | `POST` | `/api/oauth-client/v1/projects/:projectId/languages` | `projects:write` | Translation key, translation value, publishing, glossary, and Content CMS write operations use the same scopes through the MCP tools while dedicated partner REST endpoints roll out. ## Requesting scopes Pass scopes as a space-separated string in the `scope` query parameter: ``` scope=org:read+projects:read+keys:read+keys:write+translations:write ``` Request only what you need. Users are more likely to approve integrations that ask for fewer permissions. --- # Token Rotation Source: https://help.better-i18n.com/hi/docs/oauth/token-rotation ## Refresh flow ``` access_token expired → POST https://dash.better-i18n.com/api/auth/mcp/token (grant_type=refresh_token) → new access_token (30m), same refresh_token installation_token expired → POST https://api.better-i18n.com/api/oauth-client/installations/:grantId/tokens → new bi_oat_... (1h) ``` ## Refresh the access token ```bash curl -X POST https://dash.better-i18n.com/api/auth/mcp/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=" \ --data-urlencode "client_id=" \ --data-urlencode "client_secret=" ``` ### Response ```json { "access_token": "eyJhbGciOi...", "refresh_token": "rt_...", "expires_in": 1800 } ``` > [!NOTE] > The refresh token may rotate on each use. Always persist the **latest** `refresh_token` from the response, even if it looks the same. ## When refresh fails If the refresh returns `invalid_grant`, the user has **revoked your app**. Stop retrying and surface a "Reconnect" prompt — they need to re-authorize from scratch. ```json HTTP/1.1 400 Bad Request { "error": "invalid_grant" } ``` ## Practical token lifecycle ```ts title="lib/token-manager.ts" async function ensureAccessToken(conn: Connection): Promise { // Access token still valid? if (conn.accessTokenExpiresAt > Date.now() + 60_000) { return conn.accessToken; } // Refresh it const res = await fetch("https://dash.better-i18n.com/api/auth/mcp/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: conn.refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }); if (!res.ok) { // User revoked us await markConnectionRevoked(conn.id); throw new ConnectionRevokedError(); } const tok = await res.json(); await updateConnection(conn.id, { accessToken: tok.access_token, refreshToken: tok.refresh_token, accessTokenExpiresAt: Date.now() + tok.expires_in * 1000, }); return tok.access_token; } ``` --- # Revocation Source: https://help.better-i18n.com/hi/docs/oauth/revocation ## User-initiated revoke Users can revoke your app from **Settings → Connected apps** in the Better i18n dashboard. The cascade: 1. Grant marked `revokedAt` 2. Latest installation token disabled immediately 3. Refresh tokens deleted — no silent re-mint ## Partner-initiated revoke Your app can revoke its own grant when uninstalling: ```bash curl -X DELETE \ https://api.better-i18n.com/api/oauth-client/installations/ \ -H "Authorization: Bearer " ``` Same cascade applies. Useful when the user disconnects on your side and you want their tokens cleaned up immediately. ## What happens to in-flight tokens When a grant is revoked, the **latest installation token is disabled instantly**. Any cached `bi_oat_` token in your application will start returning: ```json HTTP/1.1 410 Gone { "error": "grant_revoked" } ``` The next `refresh_token` exchange will also fail with `invalid_grant`. ## After a revoke The user must re-authorize from scratch to reconnect: - New consent flow - New `grant_id` - New tokens Your integration should: 1. Mark the connection as `disconnected` in your database 2. Surface a "Reconnect" prompt in your UI 3. **Never** retry silently — it won't work --- # MCP Integration Source: https://help.better-i18n.com/hi/docs/oauth/mcp-integration The `bi_oat_` installation token works directly with the Better i18n MCP server — no additional setup needed. This means your users' AI coding agents can manage translations through the same scoped token. ## How it works ``` AI Agent (Cursor, Claude Code, ChatGPT) ↓ MCP Protocol @better-i18n/mcp server ↓ x-api-key: bi_oat_... api.better-i18n.com ↓ scope enforcement Resource APIs ``` The MCP server sends the token via `x-api-key` header. The API recognizes `bi_oat_` tokens from the `apikey` table and enforces the same scope rules as REST. ## Configure the MCP server ### stdio transport (Cursor, Claude Code) Add to your IDE's MCP config (e.g., `.cursor/mcp.json`): ```json title=".cursor/mcp.json" { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "bi_oat_your_token_here" } } } } ``` ### HTTP transport (ChatGPT, Codex, Agents SDK) Send the token as a Bearer header to the MCP HTTP endpoint: ```bash curl -X POST https://mcp.better-i18n.com/mcp \ -H "Authorization: Bearer bi_oat_your_token_here" \ -H "Content-Type: application/json" \ -d '{ ... MCP request ... }' ``` ## Available MCP tools With a scoped `bi_oat_` token, your agent can call: | Tool | Required scope | |------|---------------| | `listProjects` | `projects:read` | | `getProject` | `projects:read` | | `listKeys` | `keys:read` | | `createKeys` | `keys:write` | | `updateKeys` | `translations:write` | | `setTranslations` | `translations:write` | | `deleteKeys` | `keys:write` | | `getTranslations` | `translations:read` | | `publishTranslations` | `translations:publish` | | `getTranslationContext` | `projects:read` | If a tool requires a scope the token doesn't have, the API returns `403 missing_scope` — the agent surfaces this to the user. ## Partner auto-inject pattern If you're a platform partner (like Loveable, Replit, Bolt), you can auto-configure MCP for your users: 1. After OAuth consent, you have a `bi_oat_` token 2. Write a `.cursor/mcp.json` (or equivalent) to the user's workspace 3. The user's AI agent immediately has translation capabilities This is the **agent-native** differentiator — no competitor offers scoped, consent-driven MCP integration. ## Scope enforcement MCP tools hit the same API endpoints as REST calls. A token with only `keys:read` + `translations:read` can list and read translations but **cannot** create keys or publish. The scope boundary is enforced at the API middleware level, not at the MCP layer. --- # Error Reference Source: https://help.better-i18n.com/hi/docs/oauth/errors ## Error responses | Status | Error | Meaning | |--------|-------|---------| | `400` | `invalid_request` | Missing or malformed parameter | | `401` | `invalid_grant` | Code expired/used, redirect_uri mismatch, PKCE verification failed, or refresh token revoked | | `401` | `invalid_token` | Access or installation token expired or wrong type | | `403` | `missing_scope` | Token is valid but the route needs a scope you don't hold | | `403` | `wrong_organization` | Token's grant org doesn't match the resource's org | | `404` | `grant_not_found` | The `grantId` was deleted or never existed | | `410` | `grant_revoked` | Grant exists but was revoked by the user or partner | | `429` | `rate_limited` | Per-installation rate cap hit (1,000 req/min default) | ## Error body format All errors return a JSON body: ```json { "error": "missing_scope", "required": "translations:publish" } ``` The `required` field is only present for `missing_scope` errors. ## How to handle each error ### `401 invalid_grant` One of: - The authorization code expired (default 10 minutes) or was already exchanged - `redirect_uri` doesn't byte-for-byte match what you sent in step 1 of the authorize flow - PKCE `code_verifier` doesn't match the `code_challenge` you sent in step 1 - The refresh token was revoked **Stop retrying.** For an expired code, restart the authorize flow. For a refresh-token revoke, mark the connection dead and surface a "Reconnect" prompt. ### `401 invalid_token` Your access or installation token expired. Refresh the access token, then re-mint the installation token. If refresh also fails with `invalid_grant`, the user revoked. ### `403 missing_scope` Your token doesn't have permission for this endpoint. **Do not retry.** The user needs to re-authorize with a wider scope set. Surface the missing scope to the user. ### `410 grant_revoked` The grant was explicitly revoked. Mark the connection as dead in your database and show a reconnect prompt. The user must go through the full OAuth flow again. ### `429 rate_limited` Back off and retry after the `Retry-After` header value. Default rate limit is 1,000 requests per minute per installation token. --- # Best Practices Source: https://help.better-i18n.com/hi/docs/oauth/best-practices ## Security - **Protect your `client_secret`** — store it in environment variables or a secrets manager, never in source code, browser bundles, or client-side config. If leaked, contact us to rotate immediately - **Never store tokens client-side** — all tokens (`client_secret`, `refresh_token`, `bi_oat_`) are server-side secrets. The `bi_oat_` prefix distinguishes OAuth tokens from `bi_pub_` public keys - **`client_secret` is required** on both token exchange (`grant_type=authorization_code`) and refresh (`grant_type=refresh_token`). Requests without it return `401 Unauthorized` - **Use the narrowest scope set** — users approve fewer permissions more readily - **Handle `410 grant_revoked` as a first-class state** — don't show a generic error, show "Reconnect" - **Don't retry `403 missing_scope`** — the user must re-authorize with wider scope ## Caching - **Cache installation tokens in memory** — 1h TTL with 60s refresh window = ~2 mints/hour - **Don't persist installation tokens to disk** — they're short-lived; caching in Redis with matching TTL is fine, Postgres is wasteful - **Key cache by `(grantId, organizationId)`** — a scope change re-issues a new grant ## Token rotation - **Always persist the latest `refresh_token`** — it may rotate on each use - **Refresh proactively** — don't wait for a 401, check expiry before each API call ## Multiple organizations Each grant is scoped to one organization. The consent screen can create grants for multiple organizations in one approval when the user selects more than one. Store one connection row per returned grant. To add access later: 1. Start a new authorize flow 2. Ask the user to select the additional organization 3. Store the new `grant_id` + `refresh_token` If a user re-authorizes for an org they already have a grant for, we update the existing grant in-place. Your callback should `UPSERT` on `(userId, organizationId)`. ## What to store The minimum per connection: ```ts interface BetterI18nConnection { ownerId: string; // Your user ID grantId: string; // From token exchange refreshToken: string; // Server-side only organizationId: string; // From installation token response or organizations API scopes: string[]; // What was granted projectIds: string[]; // [] = all projects disconnectedAt?: Date; // Set on revoke } // Store separately — shared across all connections: // BETTER_I18N_CLIENT_ID (env var) // BETTER_I18N_CLIENT_SECRET (env var or secrets manager) ``` ## CDN delivery Translations published via the API are served at: ``` https://cdn.better-i18n.com/{org}/{project}/{locale}.json ``` This URL is **public** — no auth needed. Your users' apps fetch translations from the CDN at runtime using our SDKs or a simple `fetch()`. ## MCP for AI agents The same `bi_oat_` token works in the MCP server. If your platform has AI coding capabilities, inject the MCP config into the user's workspace after OAuth consent. See [MCP Integration](/oauth/mcp-integration) for details. --- # Local Development Source: https://help.better-i18n.com/hi/docs/oauth/local-development You can run the OAuth flow end-to-end against `localhost` while you build your integration. The trick is knowing which Better i18n host to point at — pick the wrong one and the popup lands on a 404 page. ## Two prod hosts, two dev hosts | Concern | Production | Local development | |---------|-----------|-------------------| | User-facing pages (login, consent, success) | `https://dash.better-i18n.com` | `http://localhost:5173` | | Server-to-server API (token, mint, REST) | `https://api.better-i18n.com` | `http://localhost:52490` | In production both hosts route to the same Cloudflare Worker, so a single base URL works for everything. In local development the dashboard runs in Vite (port `5173`) and the API runs in Wrangler (port `52490`) as two separate processes — but the dashboard's Vite dev server **proxies `/api/*`** to the API. That makes `http://localhost:5173` the right base URL for everything during dev. ## Configure your integration ```bash title=".env.local" # Point at the dashboard host. Vite proxies /api/* to the API for you. BETTER_I18N_BASE_URL=http://localhost:5173 # Your credentials from partner onboarding (see Register Your App) BETTER_I18N_CLIENT_ID=... BETTER_I18N_CLIENT_SECRET=... ``` ```ts title="lib/better-i18n.ts" const BASE_URL = process.env.BETTER_I18N_BASE_URL ?? "https://dash.better-i18n.com"; const authorizeUrl = `${BASE_URL}/api/auth/mcp/authorize?...`; const tokenUrl = `${BASE_URL}/api/auth/mcp/token`; const meUrl = `${BASE_URL}/api/oauth-client/me`; const mintUrl = `${BASE_URL}/api/oauth-client/installations/${grantId}/tokens`; ``` The same `BASE_URL` works for popup redirects (must be a host the user's browser can reach) and for server-to-server fetches (proxied through Vite to the API). ## Register dev redirect URIs Tell us your dev callback URL during partner onboarding. Common patterns: ``` http://localhost:3000/oauth/callback # Most server-side frameworks http://localhost:5173/oauth/callback # Vite + same-origin SPA http://localhost:5174/oauth/callback # Vite SPA on a non-default port ``` We add them to your application's `redirect_urls` list. The `redirect_uri` you pass at authorize time must **byte-for-byte match** one of the registered entries — protocol, host, port, path. A trailing slash counts. ## Common dev pitfalls ### "404 Not Found" on `localhost:52490/login` Your `BETTER_I18N_BASE_URL` is pointing at the API host (`52490`) instead of the dashboard host (`5173`). The popup tries to render `/login` but the API has no such route. Switch to `http://localhost:5173`. ### "Invalid redirect URI" Your dev callback URL isn't on the registered `redirect_urls` list, or it doesn't byte-for-byte match (e.g. registered `http://localhost:3000/oauth/callback` but sent `http://localhost:3000/oauth/callback/`). Ping us with the exact URL you're sending. ### "Authorization link expired" The user took longer than 10 minutes between authorize and consent, or your local dev server restarted between popup open and exchange (PKCE state in `localStorage` survives, but better-auth's verification record may have expired). Click "Connect" again to get a fresh code. ### Popup keeps reloading the dashboard host You probably ran the OAuth flow once, hit a quick error, and then the second click reuses a stale `code_verifier` from `localStorage`. Clear the storage: ```js Object.keys(localStorage) .filter(k => k.includes("pkce") || k.includes("oauth")) .forEach(k => localStorage.removeItem(k)); ``` Then try again. ## Hitting the API directly (not through the dashboard) If you're testing token exchange or `/me` from a server-side script, you can hit the API host directly. Both routes work in production; the dashboard host is only required for browser-facing redirects. ```bash # Both work in production: curl https://api.better-i18n.com/api/auth/mcp/token ... curl https://dash.better-i18n.com/api/auth/mcp/token ... # Locally only the dashboard host works (52490 is for direct curl): curl http://localhost:52490/api/auth/mcp/token ... # OK from server curl http://localhost:5173/api/auth/mcp/token ... # OK (Vite proxies) ``` For browser-initiated requests stick with the dashboard host so PKCE and the consent flow stay on a single origin. ## Next step Once your local flow works, the same code paths work in production with a single env-var swap. See the [authorization flow](/oauth/authorization-flow) for the full request reference. --- # Security Source: https://help.better-i18n.com/hi/docs/security Better i18n takes security seriously. This page outlines our security practices and how we protect your data. ## Infrastructure ### Hosting - **Cloudflare Workers** - Edge computing with built-in DDoS protection - **Cloudflare R2** - Object storage for translation files - **PlanetScale** - Serverless MySQL with automatic backups ### Data Centers All data is processed and stored in secure data centers with: - SOC 2 Type II certification - ISO 27001 compliance - GDPR compliance ## Authentication ### API Keys - API keys are hashed before storage (bcrypt) - Keys can be scoped to specific projects - Keys can be revoked instantly from the dashboard - Rate limiting prevents brute force attacks ### OAuth - GitHub OAuth for dashboard access - No passwords stored - delegated to OAuth providers - Session tokens expire after 30 days of inactivity ## Data Protection ### Encryption - **In Transit**: All connections use TLS 1.3 - **At Rest**: Database encryption using AES-256 ### Access Control - Role-based access control (RBAC) at organization level - Audit logs for all sensitive operations - Principle of least privilege for internal access ## Translation Data ### What We Store - Translation keys and values - Namespace metadata - Sync history and logs ### What We Don't Store - Source code (only translation files) - Credentials or secrets - Personal user data beyond what's needed for the service ## GitHub Integration ### Permissions We request minimal GitHub permissions: - **Repository Contents**: Read/write translation files only - **Pull Requests**: Create PRs for translation updates - **Webhooks**: Receive push events for sync ### Data Flow 1. We only access files matching your configured patterns (e.g., `locales/**/*.json`) 2. Translation files are synced to our database 3. Updates are pushed back as pull requests 4. You maintain full control over merging ## Responsible Disclosure If you discover a security vulnerability, please report it to: **security@better-i18n.com** We will: - Acknowledge receipt within 24 hours - Provide an initial assessment within 72 hours - Keep you informed of our progress - Credit you in our security acknowledgments (if desired) Please do not disclose vulnerabilities publicly until we've had a chance to address them. ## Compliance ### GDPR - Data processing agreements available - Right to erasure supported - Data export available on request ### SOC 2 We are currently working toward SOC 2 Type II certification. ## Questions? For security-related questions, contact us at **security@better-i18n.com**. --- # What is Better i18n? Source: https://help.better-i18n.com/hi/help/getting-started/what-is-better-i18n Better i18n keeps your translations out of your repository and serves them from a CDN, so changing a string does not mean shipping a build. That is the whole idea. Everything below is a consequence of it. ## The loop 1. You write `t("welcome.title")` in your code 2. The CLI scans your project and sends the keys it finds to Better i18n 3. Translations get written — by you, by AI, by whoever reviews 4. You publish 5. Your app fetches the new strings at runtime Steps 4 and 5 are where the difference lives: there is no rebuild between them. A typo fix reaches production in about a minute, and it does not need a deploy window or a developer. ## What you are working with **Project** — one app or site, addressed as `org/project` (`acme/dashboard`). Keys, languages, and settings belong to it. **Key** — an identifier like `common.save_button` with one value per language. The part before the first dot is its **namespace**, which is also the unit your app can load on its own — a page that only needs `checkout` does not have to download `admin`. **Source language** — the one your code is written in. Every translation is produced from it. ## How delivery actually behaves Published translations are served from Cloudflare's edge with `max-age=60`, which sets the honest expectation: **up to a minute** between publishing and every visitor seeing it, not "instantly". Publishing also purges the cache for what changed, so in practice it is usually faster than a minute. The TTL is the ceiling, not the wait. The SDKs layer their own caching on top — in memory, and on device for mobile — so a network blip does not leave your app without strings. There is a fallback bundle for the cold start case too. ## SDKs | Package | For | |---|---| | `@better-i18n/next` | Next.js — App Router, middleware, ISR | | `@better-i18n/remix` | Remix and Shopify Hydrogen — loader-based | | `@better-i18n/vite` | Vite apps, including TanStack Router | | `@better-i18n/expo` | React Native and Expo, with offline storage | | `better_i18n` (pub.dev) | Flutter | | `@better-i18n/server` | Node, Hono, Express — anything server-side | | `@better-i18n/sdk` | The primitive the others are built on | | `@better-i18n/cli` | Scanning, syncing, publishing, CI checks | ## What is different from JSON files in a repo | Files in your repo | Better i18n | |---|---| | A string change is a deploy | A string change is a publish | | Translators need repo access, or you paste for them | They get an editor, scoped by role | | Missing translations surface in review, or in production | Coverage per language, and a CLI check that fails CI | | Machine translation is a script someone ran once | AI translation with your glossary and page context | | Keys are managed in code only | Code, dashboard, or an AI agent over MCP | ## Where to go next - [How do I create my first project?](/help/getting-started/create-your-first-project) - [How do I add my first translation keys?](/help/getting-started/add-your-first-translations) - [How do I set up the CLI and scan my project?](/help/developer-integration/cli-quickstart) - [How do I set up Better i18n with Next.js?](/help/developer-integration/nextjs-setup) --- # Content CMS क्या है? Source: https://help.better-i18n.com/hi/help/content-management/what-is-content-cms Better i18n का Content CMS आपको संरचित बहुभाषी कॉन्टेंट — लेख, ब्लॉग पोस्ट, सहायता दस्तावेज़, उत्पाद विवरण — उसी प्लेटफ़ॉर्म पर प्रबंधित करने देता है जहाँ आपकी i18n की-ज़ रहती हैं। ## ट्रांसलेशन की-ज़ बनाम कॉन्टेंट प्रविष्टियाँ Better i18n दो तरह का कॉन्टेंट संभालता है: | प्रकार | यह क्या है | उदाहरण | |---|---|---| | **ट्रांसलेशन की-ज़** | कोड में इस्तेमाल होने वाली छोटी स्ट्रिंग्स | `auth.login_button = "Sign in"` | | **कॉन्टेंट प्रविष्टियाँ** | समृद्ध मुख्य भाग वाले संरचित दस्तावेज़ | एक पूरा सहायता लेख या ब्लॉग पोस्ट | ज़्यादातर ऐप दोनों इस्तेमाल करते हैं: UI टेक्स्ट के लिए की-ज़, लंबे कॉन्टेंट के लिए प्रविष्टियाँ। ## Content CMS आपको क्या देता है - **कॉन्टेंट मॉडल** — अपने कॉन्टेंट प्रकारों का स्कीमा परिभाषित करें (कस्टम फ़ील्ड, फ़ील्ड प्रकार, स्थानीयकृत फ़ील्ड) - **प्रविष्टियाँ** — हर कॉन्टेंट को अलग से बनाएँ और प्रबंधित करें - **बहुभाषी** — हर प्रविष्टि आपके प्रोजेक्ट की सभी भाषाओं में अनुवाद रख सकती है - **रीड API + edge कैश** — कॉन्टेंट `https://content.better-i18n.com` से परोसा जाता है, edge पर कैश होता है और प्रकाशन पर साफ़ हो जाता है - **MCP टूल** — AI एजेंट सीधे कॉन्टेंट बना, अनुवाद और प्रकाशित कर सकते हैं - **संस्करण इतिहास** — मॉडल हर सेव पर एक संशोधन रख सकते हैं, ताकि आप देख सकें कि किसी भाषा में पहले क्या लिखा था ## कॉन्टेंट आपके ऐप तक कैसे पहुँचता है ``` डैशबोर्ड / MCP एजेंट → अनुवाद सहित प्रविष्टि बनाएँ या संपादित करें → Publish → Content API कैश साफ़ होता है, नया संस्करण परोसा जाता है → आपका ऐप रनटाइम पर उसे लाता है ``` दोबारा बिल्ड करने की ज़रूरत नहीं। प्रकाशित करते ही कॉन्टेंट अपडेट लाइव हो जाते हैं। ध्यान दें कि कौन-सा हिस्सा क्या करता है: डैशबोर्ड और MCP कॉन्टेंट **लिखते** हैं; Content API केवल **पढ़ता** है। आपके ऐप के पास जो कुंजी होती है, वह सिर्फ़ कॉन्टेंट ला सकती है, और कुछ नहीं। ## Content API एंडपॉइंट ``` https://content.better-i18n.com/v1/content/{org}/{project}/models/{model}/entries ``` उदाहरण: ```bash curl "https://content.better-i18n.com/v1/content/acme/docs/models/help-article/entries?language=en&status=published" \ -H "x-api-key: YOUR_API_KEY" ``` दो बातें जो एक सपोर्ट टिकट बचा देती हैं: प्रमाणीकरण `x-api-key` हेडर से होता है (`Authorization: Bearer` से नहीं), और API अपने-आप स्थिति के आधार पर फ़िल्टर नहीं करता — `status=published` के बिना ड्राफ़्ट भी लौटते हैं। ## यह किसके लिए है? Content CMS इनके लिए उपयोगी है: - **दस्तावेज़ीकरण साइटें** — सहायता केंद्र, नॉलेज बेस - **मार्केटिंग कॉन्टेंट** — लैंडिंग पेज, ब्लॉग पोस्ट - **उत्पाद कॉन्टेंट** — फ़ीचर विवरण, रिलीज़ नोट्स - **डायनेमिक कॉन्टेंट** — कुछ भी जिसे आप बिना रीडिप्लॉय अपडेट करना चाहें ## आगे के कदम - [CMS सक्षम करें और पहला मॉडल बनाएँ](/help/enable-cms-create-first-model) - [प्रविष्टियाँ बनाना और संपादित करना](/help/creating-and-editing-entries) - [कॉन्टेंट प्रविष्टियों का अनुवाद](/help/translating-content-entries) - [Content API और MCP संदर्भ](/help/content-api-and-mcp) - [कॉन्टेंट प्रविष्टियाँ प्रकाशित करना](/help/publishing-content) --- # मैं टीम सदस्यों को कैसे आमंत्रित करूँ? Source: https://help.better-i18n.com/hi/help/team-and-account/inviting-team-members आमंत्रण प्रोजेक्ट-वार नहीं, संगठन-वार होते हैं — जिसे आप बुलाते हैं, वह पूरे संगठन में अपनी भूमिका के अनुसार अनुमत प्रोजेक्ट देख सकता है। ## आमंत्रण भेजना 1. **Members** खोलें 2. **Invite member** 3. ईमेल पता दर्ज करें 4. एक भूमिका चुनें — चुनते समय डायलॉग हर भूमिका का विवरण दिखाता है 5. भेजें उन्हें एक लिंक वाला ईमेल मिलता है। अगर उनका Better i18n खाता नहीं है, तो लिंक पहले खाता बनवाता है और फिर सीधे आपके संगठन में ले आता है। **आप कौन-सी भूमिकाएँ दे सकते हैं, यह आपकी अपनी भूमिका पर निर्भर करता है।** Owner, Admin को आमंत्रित कर सकता है; Admin नहीं कर सकता — उसे केवल Developer, Reviewer और Translator दिखते हैं। कौन क्या कर सकता है, इसके लिए देखें [भूमिकाएँ और अनुमतियाँ क्या हैं?](/help/roles-and-permissions)। ## दो टैब, दो स्थितियाँ Members पेज इन्हें अलग रखता है और हर एक पर गिनती दिखाता है: - **Members** — जिन्होंने आमंत्रण स्वीकार किया - **Pending Invites** — भेजे गए, अभी स्वीकार नहीं हुए लंबित आमंत्रण केवल उन्हीं को दिखते हैं जो सदस्य प्रबंधित कर सकते हैं। ## आमंत्रण रद्द करना **Pending Invites** में उसे ढूँढें और रद्द करें। ईमेल पते के आधार पर पुष्टि माँगी जाती है, इसलिए गलत आमंत्रण रद्द होना जानबूझकर की गई भूल से ही संभव है। रद्द होते ही ईमेल का लिंक काम करना बंद कर देता है। गलती से रद्द हो जाए तो नया आमंत्रण भेजें। ## किसी की भूमिका बदलना सदस्य सूची में उसकी भूमिका पर क्लिक करें और दूसरी चुनें। यह तुरंत लागू होती है — न दोबारा आमंत्रण, न साइन-आउट। कुछ पंक्तियाँ संपादन-योग्य नहीं होतीं, और यह कोई खराबी नहीं है: - **Owner** को सदस्य सूची से पदावनत नहीं किया जा सकता - एक **Admin दूसरे Admin को नहीं बदल सकता** — यह Owner का काम है - आप अपनी भूमिका स्वयं नहीं बदल सकते ## सदस्य हटाना सदस्य सूची से हटाएँ और पुष्टि करें। पहुँच तुरंत समाप्त हो जाती है: उनका सत्र अगली रिक्वेस्ट पर ही बंद हो जाता है, किसी बाद की समय-सीमा पर नहीं। उनके अनुवाद बने रहते हैं। किसी को हटाने से उसका काम नहीं मिटता, और अधूरा काम अपने-आप किसी और को नहीं सौंपा जाता — reviewer हटाने से पहले देख लें कि कुछ अनुमोदन-रहित तो नहीं बचा। ## बाहरी अनुवादक या एजेंसी को बुलाना **Translator** चुनें। वे हर की और उसका स्रोत टेक्स्ट पढ़ सकते हैं, अनुवाद लिख सकते हैं और AI अनुवाद इस्तेमाल कर सकते हैं। वे अनुमोदन नहीं कर सकते, की-ज़ नहीं हटा सकते, API कुंजी नहीं बना सकते, प्रोजेक्ट सेटिंग्स नहीं बदल सकते और बिलिंग नहीं देख सकते — इसलिए उनका काम समीक्षा-योग्य रहता है और कुछ भी संवेदनशील उजागर नहीं होता। अगर वही व्यक्ति अपने काम को स्वयं मंज़ूरी भी देगा, तो उसे **Reviewer** चाहिए — और इसका मतलब है दूसरी जोड़ी आँखों को छोड़ देना। ## संबंधित - [भूमिकाएँ और अनुमतियाँ क्या हैं?](/help/roles-and-permissions) - [बिलिंग और मूल्य निर्धारण कैसे काम करता है?](/help/billing-and-plans) --- # How do I use the translation editor? Source: https://help.better-i18n.com/hi/help/managing-translations/using-the-translation-editor The translation editor is the project's **Translations** tab: your keys as a table, one column per language, edited in place. ## Getting there 1. Open the project 2. Click **Translations** 3. Filter by namespace to narrow the list ## Editing a value Click the cell you want to change — the source language or any target language — and the editor opens on it. | Key | Action | |---|---| | `⌘` / `Ctrl` + `Enter` | Save | | `Esc` | Close without saving | | `⌘` / `Ctrl` + `I` | Toggle the AI drawer | `⌘S` does nothing here: saving is `⌘Enter`, because the editor is a popover on a cell rather than a document you are writing. Saving records the change. It does not publish it — see [How do I publish translations?](/help/getting-started/publish-translations-first-time). ## Placeholders are highlighted Values with variables — `{{name}}`, `{count}` — show those parameters highlighted inside the text, so you can see at a glance whether a translation kept them. That is the single most common way a translation breaks: the words are right and `{count}` is gone. Values that hold JSON open in a raw text field instead of the rich editor, so a structured value stays valid rather than being reformatted by a text editor that does not know what it is looking at. ## Notes You can attach a note to a value — why it is phrased that way, a decision someone made, a question for the next person. The note lands in that value's history rather than in a separate document nobody opens. ## History Every value carries its own history, and each entry says where the change came from: | Origin | Meaning | |---|---| | Manual edit | Someone typed it | | AI | Generated by the model | | Bulk update | Part of a multi-key operation | | Import | Came in from a file or a sync | | Note | Someone annotated the value | This is the fastest answer to "why does the German say this?" — the origin is recorded next to the change. ## The AI drawer `⌘I` opens the project's AI drawer. It is the AI path that carries your **glossary** and your project instructions, so a suggestion from here follows your terminology rather than translating in a vacuum. See [How do I manage my translation glossary?](/help/ai-and-automation/managing-glossary). ## Before you publish Pending changes are shown as a tree — what changed, in which language, in which namespace — with a publish action beside it. Read that tree before publishing rather than after: it is the difference between publishing what you meant and publishing whatever happened to be pending. ## Bulk work Select several keys to update or delete them together, and bulk AI translation covers the "fill in everything missing in German" case. Bulk edits show in each value's history as a bulk update, so a mass change is still traceable per value afterwards. There is no export from this screen. To get translations out as files, use `better-i18n pull` — see [How do I set up the CLI?](/help/developer-integration/cli-quickstart). --- # My translations aren't showing: what do I check? Source: https://help.better-i18n.com/hi/help/troubleshooting/translations-not-showing Work outwards from the CDN. One `curl` tells you whether this is a Better i18n problem or an app problem, and that answer saves you checking six things that were never wrong. ## Start here ```bash curl https://cdn.better-i18n.com/your-org/your-project/en/translations.json ``` **If the string is in there,** the platform's part is done — publishing worked, the language is live, the key exists. Skip to "Your app is not seeing it". **If it is not,** the problem is on the dashboard side. Read the next section. Check the manifest too — it is what every SDK uses to decide whether its copy is stale: ```bash curl https://cdn.better-i18n.com/your-org/your-project/manifest.json ``` `files[lang].lastModified` should be newer than your publish. If it is not, the publish did not land. ## It is not on the CDN **Nothing was published.** Writing a translation does not ship it. Run `better-i18n publish:status` — if it lists your key, it was never published. See [How do I publish translations so my app uses them?](/help/getting-started/publish-translations-first-time). **The language is a draft.** A language in Draft status is visible in the editor and excluded from publishing — deliberately, so you can work on it before it ships. Switch it to Active on the Languages page and publish again. See [How do I manage languages and track coverage?](/help/managing-translations/managing-languages). **The key does not exist.** You wrote `t("checkout.total")` but never created it. `better-i18n check:missing` lists every key your code uses that your project does not have. **The language has no value for that key.** Coverage on the Languages page tells you at a glance; the editor's Only missing filter tells you exactly which keys. ## Your app is not seeing it The string is on the CDN, so something between the CDN and the screen is in the way. **The project identifier.** It is `org/project`, exactly as it appears in your dashboard, and it is case-sensitive. A typo here fails quietly — no translations, no error. **The locale code.** The SDK asks for `/{locale}/translations.json`, so your app's locale string has to be the code in your project. The usual mismatches: | Your app might use | Better i18n has | |---|---| | `zh` | `zh-Hans` or `zh-Hant` | | `pt` | `pt-BR` or `pt-PT` | | `no` | `nb` | Compare against the Languages page, not against what looks right. **A cache in your app.** The CDN's own cache is one minute and publishing purges it, so past a minute the delay belongs to your side: SSR output, ISR, a CDN in front of your app, a service worker. [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app) goes layer by layer. **A blocked request.** Open the Network tab and look for the request to `cdn.better-i18n.com`. Blocked by a corporate proxy or a content blocker looks exactly like "translations are broken". A 200 with the right JSON means your app received the strings and did something else with them. ## Still nothing ```bash better-i18n check # what is missing, what is unused better-i18n doctor # missing translations, orphan keys, placeholder mismatches ``` `doctor` catches the case nobody looks for: a translation whose `{count}` placeholder does not match the source, which some setups render as nothing at all. ## Related - [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app) - [Locale switching updates content but not UI translations (SSR)](/help/troubleshooting/locale-switch-ui-translations-stale-ssr) - [How do I use i18n Doctor to check translation health?](/help/troubleshooting/using-i18n-doctor) --- # How do I use the MCP server with AI coding agents? Source: https://help.better-i18n.com/hi/help/ai-and-automation/mcp-server-for-agents The Better i18n MCP server lets coding agents — Claude Code, Cursor, Windsurf, Zed, Codex — manage your translations and content: create keys, translate, publish, edit content entries. ## Two ways to connect **Remote (recommended, no API key).** Agents that speak remote MCP sign in with OAuth: ``` https://mcp.better-i18n.com/mcp ``` In Cursor: **Settings → MCP → Add new MCP server → Remote**, paste that URL, and your browser opens to sign in on first use. In Claude Code: ```bash claude mcp add --transport http --scope user better-i18n https://mcp.better-i18n.com/mcp ``` **Local bridge (needs an API key).** For agents that only run stdio servers: ```json { "mcpServers": { "better-i18n": { "command": "npx", "args": ["-y", "@better-i18n/mcp@latest"], "env": { "BETTER_I18N_API_KEY": "bi-..." } } } } ``` Or in one line: ```bash claude mcp add better-i18n -s user -e BETTER_I18N_API_KEY=bi-... -- npx -y @better-i18n/mcp@latest ``` Account keys look like `bi-…` (**Settings → API Keys**). Content delivery keys (`bi_pub_…`) are read-only and will not work here. For **ChatGPT**, **Claude on the web** or **Gemini**, use the AI Assistants setup instead — nothing to install. ## Project context The server needs to know which project it is managing. It reads `projectId` from your `i18n.config.ts`: ```typescript export const i18n = createI18n({ projectId: "your-org/your-project", defaultLocale: "en", }); ``` ## Two servers, two toolsets | Package | Covers | |---|---| | `@better-i18n/mcp` | Translation keys, languages, publishing, sync history | | `@better-i18n/mcp-content` | Content CMS models, fields, entries | Add the content server the same way when your project uses the CMS. ### Translation tools | Tool | What it does | |---|---| | `listProjects` · `getProject` | Discover projects; languages, namespaces, coverage | | `listKeys` | Browse keys, compact and paginated | | `getTranslations` | The actual translation text — what an AI task needs | | `createKeys` · `updateKeys` · `deleteKeys` | Manage keys | | `setTranslations` | Write translation values | | `getTranslationContext` | Surrounding context for a key, so a translation is not guessed blind | | `proposeLanguages` · `proposeLanguageEdits` | Suggest languages or edits for review instead of applying them | | `getPendingChanges` | What would go live on the next publish | | `publishTranslations` | Publish to the CDN | | `getSyncs` · `getSync` · `cancelSync` | Sync history and control | ### Content tools | Tool | What it does | |---|---| | `listContentModels` · `getContentModel` | Inspect schemas | | `createContentModel` · `updateContentModel` · `deleteContentModel` | Manage schemas | | `addField` · `updateField` · `removeField` · `reorderFields` | Manage fields | | `listContentEntries` · `getContentEntry` | Find and read entries | | `createContentEntry` · `updateContentEntry` · `duplicateContentEntry` | Write entries | | `bulkCreateEntries` · `bulkUpdateEntries` | Up to 200 entries per call, partial failures reported | | `publishContentEntry` · `bulkPublishEntries` | Publish (up to 500 ids in bulk) | | `deleteContentEntry` | Move an entry to Trash | Two habits that save calls: pass every language in the **initial** `createContentEntry` rather than looping updates, and use `missingLanguage=fr` — not `language=fr` — to find what still needs translating. ## Example prompts ``` "Add common.save_button with value 'Save' and translate it to Turkish, French and Spanish" "Find keys in the auth namespace that have no German translation and translate them" "Show me what would go live if I published right now" "Create a help article about getting started, translate it to every project language, publish it" ``` ## What agents can and cannot do They work through the tools above — so keys, translations, languages, publishing, content and sync history are in scope. Billing, deleting a project or an organisation, and inviting team members are not: there is no tool for any of them, which is a stronger guarantee than a permission setting. Deleting an entry is the softest destructive action available, and it goes to Trash rather than disappearing. ## Seeing what the agent did MCP operations land in the project's activity log attributed to an agent rather than to a person, so a run you did not watch is still reviewable afterwards. --- # Which languages can I translate into? Source: https://help.better-i18n.com/hi/help/managing-translations/supported-languages Better i18n ships **215 languages**: 183 base languages on ISO 639-1 codes, plus 32 regional and script variants on BCP 47. You pick from that list in **Languages → Add language** inside your project — nothing has to be requested, enabled or upgraded first. **Looking for one in particular?** Search this page for its name (⌘F / Ctrl+F). The tables below are grouped by region and give the native name alongside the code, because the native name is what your translators will recognise. ## Two questions people mean by "do you support X?" Most confusion here comes from collapsing two separate things. Keep them apart: | Question | Answer | |---|---| | Can Better i18n **hold** this language? | Yes, for all 215. Keys, the editor, coverage tracking, CDN delivery and the SDKs behave identically for every language on the list. | | Can a machine-translation provider **fill** it automatically? | That depends on the provider you connected. DeepL covers roughly 30 languages; Google and Azure cover several hundred. | A language your provider does not reach is still a fully working language. You or your translators fill it in the editor, or AI translation fills what it can and you review it. Nothing about the platform degrades — only the automatic first draft is unavailable. If automatic coverage matters to you, check [translation providers](https://help.better-i18n.com/help/developer-integration/translation-providers) before you pick one. Google and Azure are the widest. ## Your language is not in the list The list follows ISO 639-1, and that standard assigns codes to languages, not to every variety or dialect. Bhoti, Ladakhi, Balti, Konkani dialects and similar varieties have no entry of their own. Two ways forward: 1. **Use the closest coded language.** Bhoti is a Tibetic language written in Tibetan script, so `bo` (Tibetan) is where it belongs. Pair it with a [glossary](https://help.better-i18n.com/help/ai-and-automation/managing-glossary) and [translation guidelines](https://help.better-i18n.com/help/ai-and-automation/translation-guidelines) so AI translation and your reviewers keep the wording you want. This is the usual answer. 2. **Ask us to add the code.** Language codes come from a shared reference list, so you cannot type in an arbitrary one — the project will reject it. If you need a code that genuinely is not there, message us in the widget with the code and language name and we will add it. ## Right-to-left languages Five are right-to-left: Arabic (`ar`), Persian (`fa`), Hebrew (`he`), Urdu (`ur`) and Yiddish (`yi`). Direction is a property of the language itself, so the translation editor renders them RTL and the SDK exposes the direction to your app. You configure nothing. ## European languages The widest coverage on the list, including every EU official language. | Language | Native name | Code | |---|---|---| | Albanian | Shqip | `sq` | | Aragonese | aragonés | `an` | | Avaric | Авар мацӀ | `av` | | Bashkir | башҡорт теле | `ba` | | Basque | Euskara | `eu` | | Belarusian | беларуская мова | `be` | | Bosnian | bosanski jezik | `bs` | | Breton | brezhoneg | `br` | | Bulgarian | български език | `bg` | | Catalan | català | `ca` | | Chechen | нохчийн мотт | `ce` | | Chuvash | чӑваш чӗлхи | `cv` | | Cornish | Kernewek | `kw` | | Corsican | Corsu | `co` | | Croatian | hrvatski jezik | `hr` | | Czech | Čeština | `cs` | | Danish | dansk | `da` | | Dutch | Nederlands | `nl` | | English | | `en` | | Estonian | Eesti | `et` | | Faroese | føroyskt | `fo` | | Finnish | Suomi | `fi` | | French | Français | `fr` | | Galician | galego | `gl` | | German | Deutsch | `de` | | Greek | ελληνικά | `el` | | Hungarian | Magyar | `hu` | | Icelandic | Íslenska | `is` | | Irish | Gaeilge | `ga` | | Italian | Italiano | `it` | | Kalaallisut | | `kl` | | Komi | коми кыв | `kv` | | Latin | latine, lingua latina | `la` | | Latvian | latviešu valoda | `lv` | | Limburgish | Limburgs | `li` | | Lithuanian | lietuvių kalba | `lt` | | Luxembourgish | Lëtzebuergesch | `lb` | | Macedonian | македонски јазик | `mk` | | Maltese | Malti | `mt` | | Manx | Gaelg, Gailck | `gv` | | Northern Sami | Davvisámegiella | `se` | | Norwegian | Norsk | `no` | | Norwegian Bokmål | Norsk bokmål | `nb` | | Norwegian Nynorsk | Norsk nynorsk | `nn` | | Occitan | occitan, lenga d'òc | `oc` | | Old Church Slavonic | ѩзыкъ словѣньскъ | `cu` | | Polish | Polski | `pl` | | Portuguese | Português | `pt` | | Romanian | Română | `ro` | | Romansh | rumantsch grischun | `rm` | | Russian | Русский | `ru` | | Sardinian | sardu | `sc` | | Scottish Gaelic | Gàidhlig | `gd` | | Serbian | српски језик | `sr` | | Slovak | Slovenčina | `sk` | | Slovene | Slovenski jezik | `sl` | | Spanish | Español | `es` | | Swedish | svenska | `sv` | | Tatar | Татар теле | `tt` | | Ukrainian | Українська | `uk` | | Walloon | walon | `wa` | | Welsh | Cymraeg | `cy` | | Western Frisian | Frysk | `fy` | ## Middle East and North African languages Arabic, Hebrew and Persian are right-to-left; direction is set on the language, so you configure nothing. | Language | Native name | Code | |---|---|---| | Arabic | العربية | `ar` | | Avestan | avesta | `ae` | | Azerbaijani | Azerbaycan Türkçesi | `az` | | Hebrew | עברית | `he` | | Persian | فارسی | `fa` | | Turkish | Türkçe | `tr` | | Yiddish | ייִדיש | `yi` | ## Sub-Saharan African languages Machine-translation coverage is thinnest here — Google and Azure reach most of these, DeepL reaches none of them. They work exactly the same in the editor either way. | Language | Native name | Code | |---|---|---| | Afar | Afaraf | `aa` | | Afrikaans | | `af` | | Akan | | `ak` | | Amharic | አማርኛ | `am` | | Bambara | bamanankan | `bm` | | Chichewa | chiCheŵa, chinyanja | `ny` | | Ewe | Eʋegbe | `ee` | | Fula | Fulfulde | `ff` | | Ganda | Luganda | `lg` | | Hausa | (Hausa) هَوُسَ | `ha` | | Herero | Otjiherero | `hz` | | Igbo | Asụsụ Igbo | `ig` | | Kanuri | | `kr` | | Kikuyu | Gĩkũyũ | `ki` | | Kinyarwanda | Ikinyarwanda | `rw` | | Kirundi | Ikirundi | `rn` | | Kongo | Kikongo | `kg` | | Kwanyama | Kuanyama | `kj` | | Lingala | Lingála | `ln` | | Luba-Katanga | Tshiluba | `lu` | | Malagasy | fiteny malagasy | `mg` | | Ndonga | Owambo | `ng` | | Northern Ndebele | isiNdebele | `nd` | | Oromo | Afaan Oromoo | `om` | | Sango | yângâ tî sängö | `sg` | | Shona | chiShona | `sn` | | Somali | Soomaaliga | `so` | | Southern Ndebele | isiNdebele | `nr` | | Southern Sotho | Sesotho | `st` | | Swahili | Kiswahili | `sw` | | Swati | SiSwati | `ss` | | Tigrinya | ትግርኛ | `ti` | | Tsonga | Xitsonga | `ts` | | Tswana | Setswana | `tn` | | Twi | | `tw` | | Venda | Tshivenḓa | `ve` | | Wolof | Wollof | `wo` | | Xhosa | isiXhosa | `xh` | | Yoruba | Yorùbá | `yo` | | Zulu | isiZulu | `zu` | ## South Asian languages Several use their own script, and the editor renders each one in it — Devanagari, Bengali, Tamil, Sinhala, Thaana. | Language | Native name | Code | |---|---|---| | Assamese | অসমীয়া | `as` | | Bengali | বাংলা | `bn` | | Bihari | भोजपुरी | `bh` | | Divehi | ދިވެހި | `dv` | | Dzongkha | རྫོང་ཁ | `dz` | | Gujarati | ગુજરાતી | `gu` | | Hindi | हिन्दी | `hi` | | Kannada | ಕನ್ನಡ | `kn` | | Kashmiri | कश्मीरी | `ks` | | Malayalam | മലയാളം | `ml` | | Marathi | मराठी | `mr` | | Nepali | नेपाली | `ne` | | Oriya | ଓଡ଼ିଆ | `or` | | Pāli | पाऴि | `pi` | | Pashto | پښتو | `ps` | | Punjabi | ਪੰਜਾਬੀ | `pa` | | Sanskrit | संस्कृतम् | `sa` | | Sindhi | सिन्धी | `sd` | | Sinhalese | සිංහල | `si` | | Tamil | தமிழ் | `ta` | | Telugu | తెలుగు | `te` | | Urdu | اردو | `ur` | ## East and Southeast Asian languages Chinese is here as the base code `zh`; if you need Simplified and Traditional as separate languages, use the script variants below. | Language | Native name | Code | |---|---|---| | Burmese | ဗမာစာ | `my` | | Chinese | 中文 | `zh` | | Indonesian | Bahasa Indonesia | `id` | | Japanese | 日本語 | `ja` | | Javanese | ꦧꦱꦗꦮ | `jv` | | Khmer | ខ្មែរ | `km` | | Korean | 한국어 | `ko` | | Lao | ພາສາລາວ | `lo` | | Malay | Bahasa Melayu | `ms` | | Mongolian | Монгол хэл | `mn` | | Nuosu | ꆈꌠ꒿ Nuosuhxop | `ii` | | Sundanese | Basa Sunda | `su` | | Tagalog | Wikang Tagalog | `tl` | | Thai | ไทย | `th` | | Tibetan | བོད་ཡིག | `bo` | | Uyghur | ئۇيغۇرچە | `ug` | | Vietnamese | Tiếng Việt | `vi` | | Zhuang | Saɯ cueŋƅ | `za` | ## Central Asian and Caucasian languages Several use non-Latin scripts. The editor renders each one in its own script, and the native name is what your translators will recognise. | Language | Native name | Code | |---|---|---| | Abkhaz | Аҧсуа бызшәа | `ab` | | Armenian | Հայերեն | `hy` | | Georgian | ქართული | `ka` | | Kazakh | қазақ тілі | `kk` | | Kyrgyz | Кыргызча | `ky` | | Ossetian | ирон æвзаг | `os` | | Tajik | Тоҷикӣ | `tg` | | Turkmen | Türkmen | `tk` | | Uzbek | Oʻzbek | `uz` | ## Languages of the Americas Indigenous languages of the Americas. For Spanish and Portuguese as spoken here, the regional variants below are usually what you want. | Language | Native name | Code | |---|---|---| | Aymara | aymar aru | `ay` | | Cree | ᓀᐦᐃᔭᐍᐏᐣ | `cr` | | Guaraní | Avañe'ẽ | `gn` | | Haitian Creole | Kreyòl ayisyen | `ht` | | Inuktitut | ᐃᓄᒃᑎᑐᑦ | `iu` | | Inupiaq | Iñupiaq | `ik` | | Navajo | Diné bizaad | `nv` | | Ojibwe | ᐊᓂᔑᓈᐯᒧᐎᓐ | `oj` | | Quechua | Runa Simi | `qu` | ## Pacific languages Small speaker populations, full platform support. No machine-translation provider covers most of them, so expect to translate these by hand or with AI review. | Language | Native name | Code | |---|---|---| | Bislama | | `bi` | | Chamorro | Chamoru | `ch` | | Fijian | vosa Vakaviti | `fj` | | Hiri Motu | | `ho` | | Māori | te reo Māori | `mi` | | Marshallese | Kajin M̧ajeļ | `mh` | | Nauruan | Dorerin Naoero | `na` | | Samoan | gagana fa'a Samoa | `sm` | | Tahitian | Reo Tahiti | `ty` | | Tonga | faka Tonga | `to` | ## Regional and script variants Add one of these alongside the base language when the copy genuinely differs — `pt-BR` and `pt-PT` are two independent languages here, with their own keys, coverage and publish status. Codes come out in BCP 47 casing, so they drop into `next-intl`, `react-i18next`, Expo or Flutter without a mapping layer. | Language | Code | |---|---| | German (Austria) | `de-AT` | | German (Switzerland) | `de-CH` | | English (Australia) | `en-AU` | | English (Canada) | `en-CA` | | English (India) | `en-IN` | | English (Ireland) | `en-IE` | | English (New Zealand) | `en-NZ` | | English (Singapore) | `en-SG` | | English (South Africa) | `en-ZA` | | English (UK) | `en-GB` | | English (US) | `en-US` | | Spanish (Argentina) | `es-AR` | | Spanish (Chile) | `es-CL` | | Spanish (Colombia) | `es-CO` | | Spanish (Latin America) | `es-419` | | Spanish (Mexico) | `es-MX` | | Spanish (Spain) | `es-ES` | | French (Belgium) | `fr-BE` | | French (Canada) | `fr-CA` | | French (Switzerland) | `fr-CH` | | Italian (Switzerland) | `it-CH` | | Dutch (Belgium) | `nl-BE` | | Norwegian Bokmål | `nb-NO` | | Norwegian Nynorsk | `nn-NO` | | Portuguese (Brazil) | `pt-BR` | | Portuguese (Portugal) | `pt-PT` | | Serbian (Cyrillic) | `sr-Cyrl` | | Serbian (Latin) | `sr-Latn` | | Swedish (Finland) | `sv-FI` | | Chinese (Simplified) | `zh-Hans` | | Chinese (Traditional) | `zh-Hant` | | Chinese (Hong Kong) | `zh-HK` | ## Constructed languages Available for completeness. These are real entries you can add, not placeholders. Esperanto `eo` · Ido `io` · Interlingua `ia` · Interlingue `ie` · Volapük `vo` ## What this does not cover Languages here mean **translation target languages** in your project. Two related things are separate: - **Help centre and CMS content** have their own language settings, set per content entry — see [translating content entries](https://help.better-i18n.com/help/content-management/translating-content-entries). - **Document translation** — sending a PDF, DOCX or slide deck and getting the same file back translated — is not something Better i18n does. It translates the strings your application ships. [More on what counts as a translation file](https://help.better-i18n.com/help/developer-integration/supported-file-formats). --- # Which translation file formats are supported? Source: https://help.better-i18n.com/hi/help/developer-integration/supported-file-formats Better i18n reads four file formats on the way **in**, and always writes JSON on the way **out**. Those are two different lists, and knowing which one you are asking about saves a lot of confusion. ## Formats you can upload Uploading a translation file directly — in the dashboard or through the API — accepts: | Format | Extensions | |---|---| | JSON | `.json` | | YAML | `.yaml`, `.yml` | | XML / XLIFF | `.xml`, `.xliff` | | Java properties | `.properties` | The format is detected from the file extension, so name the file normally and upload it. Once the keys are in, the original format stops mattering — everything after that point works the same regardless of what you uploaded. ## GitHub sync reads JSON only If you connect a repository instead of uploading, the importer looks for `.json` files. YAML, XLIFF and `.properties` are upload-only today. So a repo with `locales/en.yaml` will not import over GitHub sync. Two options: upload the YAML once to get your keys in, or convert the files in your repo to JSON. ## What Better i18n writes back Output is always JSON, in both destinations: | Destination | Shape | |---|---| | CDN | `{org}/{project}/{lang}/{namespace}.json` | | GitHub pull requests | `{yourTranslationPath}/{lang}.json`, or `{lang}/{namespace}.json` when you use namespaced folders | This is deliberate — every SDK we ship (Next.js, Remix, Vite, Expo, Flutter, plain `@better-i18n/core`) consumes JSON, and the CDN serves it with a manifest so a string change reaches production without a rebuild. If you need a different shape in your repo, transform it in your build step after pulling. ## The three layouts Independently of format, one setting decides how the files are laid out. The GitHub integration page lists it as **Supported Formats**, and there are three: | Layout | Example path | What it means | |---|---|---| | **Flat JSON** | `/locales/en.json` | One file per language, all keys at the root with dot-notation: `"auth.login.title": "Sign in"` | | **Nested JSON** | `/locales/en.json` | One file per language, top-level keys as namespaces and nested objects underneath | | **Namespaced Folders** | `/locales/en/common.json` | One folder per language, one file per namespace inside it | Pick the one your app already reads and the files Better i18n writes drop in without a migration. If you connected a repository, we detect the layout during setup and show you what we found before anything is written — check it there rather than setting it blind. ## Locale codes in filenames Filenames carry the locale, and regional codes are fine: `en.json`, `en-US.json`, `pt-BR.json`, `zh-Hans.json` all resolve correctly. You do not have to flatten `en-US.json` down to `en.json` before importing. ## Can you translate a PDF and send it back? No. Better i18n does not translate documents — not PDF, not DOCX, not slide decks or spreadsheets — and there is no way to upload a file and get the same file back in another language with the layout intact. That is a different product category (document translation); this one translates the strings your application ships. If you uploaded a PDF and nothing happened, that is why. Nothing is wrong with the file. What we do instead: your app's text lives as keys, you add target languages, and every language is delivered as JSON your app reads at runtime. If the text you want translated is currently only inside a document, it has to become keys first — copy the strings into a JSON file and upload that. ## What is not a translation file A translation file is a key-value file your application already reads: `.json`, `.yaml`, `.xml` / `.xliff`, `.properties`. Product documents are not, and neither are design files, screenshots, or database dumps. The test is simple — if your code does not load it to render text, it is not a translation file. --- # Which SDK do I need and how do I install it? Source: https://help.better-i18n.com/hi/help/developer-integration/which-sdk-and-how-to-install There is one SDK per framework, and installing it is a single package plus a provider. Pick the row that matches your stack. | Stack | Install | |---|---| | Next.js (App or Pages Router) | `npm i @better-i18n/next` | | Vite + React | `npm i @better-i18n/use-intl @better-i18n/vite` | | TanStack Start | `npm i @better-i18n/use-intl` | | Remix or Shopify Hydrogen | `npm i @better-i18n/remix` | | Expo / React Native | `npm i @better-i18n/expo` | | Flutter | `flutter pub add better_i18n` | | iOS / Swift | `BetterI18n` via Swift Package Manager | | Node backend (Hono, Express, Fastify) | `npm i @better-i18n/server` | | Anything else | `npm i @better-i18n/core` | All of them do the same job: fetch your published translations from the CDN at runtime, cache them, and hand your app a `t()`. They differ only in how they hook into that framework's rendering — which is why there is no single "the SDK" to install. ## What you configure Every SDK needs the same two values, and nothing else to get started: - **Project** — `your-org/your-project`, the same string the dashboard shows - **Source language** — the language your keys are written in Everything else has a default. Published translations are served from a public CDN, so the browser bundle carries no credentials of any kind. ## The three-step shape 1. Install the package for your stack from the table above 2. Wrap your app in that SDK's provider, passing your project 3. Replace hardcoded strings with `t("your.key")` Framework-specific walkthroughs, with the actual provider code: - [Set up Better i18n with Next.js](https://help.better-i18n.com/help/developer-integration/nextjs-setup) - [Set up the CLI and scan your project](https://help.better-i18n.com/help/developer-integration/cli-quickstart) — finds the hardcoded strings for you - [How It Works](https://help.better-i18n.com/docs/core/how-it-works) — the CDN contract every SDK reads ## Packages that are not translation SDKs Four packages share the `@better-i18n` scope but do a different job. Installing one of these when you wanted translations is a common wrong turn: | Package | What it is for | |---|---| | `@better-i18n/sdk` | Content CMS client — fetches content models and entries, not UI strings | | `@better-i18n/content` | Content analytics tracking | | `@better-i18n/admin` | Server-side admin: manage projects, keys, translations programmatically | | `@better-i18n/cli` | Command line: scan for hardcoded strings, publish, check publish status | ## Which one if you use two frameworks A monorepo with a Next.js web app and an Expo app installs both `@better-i18n/next` and `@better-i18n/expo`. They can point at the same project, or at separate projects if the copy genuinely differs — separate projects mean separate keys and separate coverage. ## Do I need an SDK at all No. The CDN is plain JSON over HTTPS, so you can fetch it yourself and skip every package on this page — see [How It Works](https://help.better-i18n.com/docs/core/how-it-works) for the URL shape. The SDKs exist for caching, SSR without a flash of untranslated content, and locale switching without a reload. If you only need the strings, fetch the file. --- # Is Better i18n open source, and can I use it for free? Source: https://help.better-i18n.com/hi/help/team-and-account/open-source-and-licensing Partly. Every SDK, the CLI and the MCP servers are open source under the MIT licence, public on GitHub. The hosted platform, meaning the dashboard and the translation pipeline, is closed source. And yes, you can use the whole thing for free without paying anything. The split matters more than the label, so here it is in full. ## Where is the source code, and where do I start reading it All public code lives in one repository: [github.com/better-i18n/oss](https://github.com/better-i18n/oss). To find your way around the codebase: - `packages/core` is the base layer. It fetches translations from the CDN, caches them and normalises locales. Every other SDK builds on it, so read it first. - `packages/next`, `packages/use-intl`, `packages/expo`, `packages/remix` and `packages/server` are thin adapters for each framework. - `packages/cli` is the command line tool. Its `scan` command reads your own app and finds the translation keys it uses ([how scan works](https://docs.better-i18n.com/en/docs/cli/scan)). - `packages/mcp` and `packages/mcp-content` are the MCP servers AI assistants use. If you want to contribute, open an issue or a pull request on that repository. ## What is open source and what is not | Part | Licence | Where | |---|---|---| | Every SDK, the CLI, the MCP servers | **MIT** | [github.com/better-i18n/oss](https://github.com/better-i18n/oss) | | The docs site, the marketing site, the help centre | **MIT** | same repository | | Example apps and starters | **MIT** | [nextjs-i18n-starter](https://github.com/better-i18n/nextjs-i18n-starter), [demo-i18n-app](https://github.com/better-i18n/demo-i18n-app) | | The dashboard, API and sync engine | Closed source | hosted by us | You can read, fork, patch and vendor every line that ships inside your application. What you cannot get the source of is the editor, the translation pipeline and the CDN that serves your strings. ## Which packages are MIT licensed All fourteen published ones: `@better-i18n/core` · `@better-i18n/next` · `@better-i18n/remix` · `@better-i18n/use-intl` · `@better-i18n/vite` · `@better-i18n/expo` · `@better-i18n/server` · `@better-i18n/sdk` · `@better-i18n/content` · `@better-i18n/admin` · `@better-i18n/cli` · `@better-i18n/mcp` · `@better-i18n/mcp-content` · `@better-i18n/schemas` · plus `better_i18n` on pub.dev for Flutter No package requires a licence key to run, and none of them phone home for authorisation. They fetch published translations from a public CDN over plain HTTPS. ## Can I use it without paying anything Yes. The Free plan is not a trial that expires: | Free plan | Limit | |---|---| | Projects | 5 | | Translation keys | 5,000 | | Content entries | 100 | | AI translation requests | 500 per month | | Languages | unlimited | | Team members | unlimited, and they never cost extra | Billing is per organisation, not per seat or per project. See [billing and plans](https://help.better-i18n.com/help/team-and-account/billing-and-plans) for what Pro adds. If you outgrow 5,000 keys, that is the point at which it costs money. Below it, nothing does. ## Can I self-host the platform No, and this is the half people misread most often, so both parts plainly. **The platform cannot be self-hosted.** The dashboard, the translation editor, the AI translation pipeline, GitHub sync and the publish flow run on our infrastructure and are not open source. There is no on-premise or Docker build today. **Your translation files can be self-hosted.** Export the JSON, put it on your own CDN or server, and point the SDK at it with `cdnBaseUrl`. Your app then fetches strings from infrastructure you control and never talks to us at runtime. See [how it works](https://help.better-i18n.com/docs/core/how-it-works). Those are different things and the second one is not a workaround for the first. If your requirement is "no third-party service in the runtime path", exporting to your own CDN covers you completely. If it is "no third-party service anywhere, including where translators work", Better i18n is not the right fit and it is better to know that now than after a migration. ## Why the split The client code is where you take on risk by adopting us. It sits in your bundle, in your build, in your CI. MIT means that risk is yours to inspect and yours to keep even if we disappear, because you can fork it and keep shipping. The platform is the product we sell, and keeping it closed is what pays for the free plan. --- # How do I set up the CLI and scan my project? Source: https://help.better-i18n.com/hi/help/developer-integration/cli-quickstart The Better i18n CLI lets you scan your codebase, sync keys, and publish translations from the terminal. ## Installation ```bash npm install -g @better-i18n/cli # or bun add -g @better-i18n/cli ``` The binary is `better-i18n`. ## Authentication ```bash better-i18n login ``` The key is stored at `~/.better-i18n/auth.json`. `better-i18n whoami` prints who you are and where the credential came from — the file, or the `BETTER_I18N_API_KEY` environment variable, which takes over in CI. You can also grab a key by hand from **Settings → API Keys** in the dashboard. ## Configuration The CLI reads your project from **`i18n.config.ts`** (or `.js`) — the same config your SDK uses, so there is usually nothing new to create. It is looked for in the working directory first, then in subdirectories. ```typescript export const project = "your-org/your-project"; export const defaultLocale = "en"; ``` Both `project` and `projectId` are accepted for the `"org/project"` value; the dashboard shows the name either way. ## Core commands ### `scan` — find translation keys in your code ```bash better-i18n scan ``` Reads your source files for `t()` calls and reports what it found. Changes nothing. ### `sync` — push keys to Better i18n ```bash better-i18n sync ``` Creates keys found in your code; unchanged keys are skipped. ### `pull` — bring remote translations back down ```bash better-i18n pull ``` ### `check` — missing and unused keys ```bash better-i18n check # interactive better-i18n check:missing # in code, not in the project better-i18n check:unused # in the project, not found in code ``` All three take `--format eslint|json` (default `eslint`), `--dir ` and `--verbose`. The `json` format is the one to use in CI, since you can act on it. ### `publish` — push translations to the CDN ```bash better-i18n publish:status # what is pending better-i18n publish # publish it ``` `publish:status` first is a habit worth having — it is the difference between publishing what you meant and publishing what happened to be pending. ### `doctor` — health check ```bash better-i18n doctor ``` ### Other commands | Command | What it does | |---|---| | `projects` · `project` | List projects · show one project's languages, namespaces, coverage | | `keys list` · `keys create` · `keys delete` | Manage keys directly | | `translate` | Set translations for existing keys (JSON on stdin) | | `translations` | Fetch translations with their full text | | `languages add` · `languages edit` | Add target languages · change a language's status | | `syncs list` · `syncs get ` · `syncs cancel ` | Sync/publish job history | | `content:types` | Generate types for your Content CMS models | | `whoami` · `logout` | Session | ## CI/CD integration ```yaml # GitHub Actions - name: Check i18n health run: better-i18n check:missing --format json env: BETTER_I18N_API_KEY: ${{ secrets.BETTER_I18N_API_KEY }} ``` The command exits non-zero when it finds problems, which is what fails the step — there is no `--fail-on-*` flag to add. ## Using environment variables ```bash export BETTER_I18N_API_KEY=bi-... better-i18n sync ``` An account key looks like `bi-…`; content delivery keys look like `bi_pub_…` and are read-only, so the CLI wants the former. ## Next steps - [Set up GitHub sync](/help/developer-integration/github-sync-setup) - [Run i18n Doctor](/help/troubleshooting/using-i18n-doctor) - [Publish translations](/help/getting-started/publish-translations-first-time) --- # मैं CMS कैसे सक्षम करूँ और अपना पहला कॉन्टेंट मॉडल कैसे बनाऊँ? Source: https://help.better-i18n.com/hi/help/content-management/enable-cms-create-first-model Content CMS एक वैकल्पिक ऐड-ऑन है, जिससे आप अपनी i18n की-ज़ के साथ-साथ संरचित बहुभाषी कॉन्टेंट — ब्लॉग पोस्ट, सहायता लेख, रिलीज़ नोट्स, उत्पाद विवरण — प्रबंधित कर सकते हैं। ## क्या आपको Content CMS चाहिए? Content CMS समृद्ध संरचना वाले **मुक्त-रूप कॉन्टेंट** के लिए है। अगर आप कोड में `t('key')` स्ट्रिंग्स संभाल रहे हैं, तो वह काम Translation API करता है — उसके लिए CMS की ज़रूरत नहीं। Content CMS तब इस्तेमाल करें जब आपको चाहिए: - markdown मुख्य भाग वाले लेख या दस्तावेज़ - कस्टम फ़ील्ड वाला कॉन्टेंट (लेखक, तिथि, श्रेणी) - अलग-अलग प्रबंधित किए जाने वाले कई भाषा संस्करण - ऐप में बंडल करने के बजाय API या CDN से परोसा जाने वाला कॉन्टेंट ## चरण 1: Content CMS सक्षम करें 1. डैशबोर्ड में अपना प्रोजेक्ट खोलें 2. **Content** टैब पर क्लिक करें 3. अगर पहले से सक्षम नहीं है, तो **"Enable Content CMS"** पर क्लिक करें Content टैब सभी प्लान में उपलब्ध है। कुछ उन्नत सुविधाओं (कस्टम मॉडल, थोक कार्रवाइयाँ) के लिए ऊँचा प्लान चाहिए हो सकता है। ## चरण 2: अपना पहला कॉन्टेंट मॉडल बनाएँ कॉन्टेंट मॉडल आपके कॉन्टेंट प्रकार का स्कीमा है। इसे डेटाबेस टेबल की परिभाषा की तरह समझें। 1. Content टैब में **"New model"** पर क्लिक करें 2. एक नाम (जैसे "Help Article") और एक slug (जैसे `help-article`) दर्ज करें 3. कस्टम फ़ील्ड जोड़ें: - **Text** — छोटी स्ट्रिंग्स (शीर्षक, लेखक) - **Textarea** — लंबा टेक्स्ट (सारांश, परिचय) - **Number** — पूर्णांक या दशमलव - **Boolean** — सही/ग़लत फ़्लैग - **Enum** — तय सूची से ड्रॉपडाउन - **Relation** — किसी अन्य कॉन्टेंट मॉडल का संदर्भ 4. जिन फ़ील्ड का प्रति-भाषा अनुवाद चाहिए, उनके लिए **"Localized"** चालू करें 5. **Create model** पर क्लिक करें मॉडल-स्तर का एक निर्णय बाद के बजाय अभी ले लेना बेहतर है: क्या इस मॉडल की प्रविष्टियों में मुख्य भाग होगा? मुख्य भाग वाले मॉडल (लेख, पोस्ट) किसी भाषा को प्रकाशित करने से पहले उस भाषा में ग़ैर-खाली सामग्री माँगते हैं। केवल मेटाडेटा वाले मॉडल — टैग, श्रेणियाँ और अन्य वर्गीकरण — सिर्फ़ अपने फ़ील्ड के आधार पर प्रकाशित हो जाते हैं। ## चरण 3: अपनी पहली प्रविष्टि बनाएँ 1. Content टैब में अपना मॉडल चुनें 2. **"New entry"** पर क्लिक करें 3. शीर्षक, मुख्य भाग और कस्टम फ़ील्ड भरें 4. **Save** या **Publish** पर क्लिक करें प्रकाशित होने के बाद प्रविष्टि Content API से उपलब्ध हो जाती है: ```bash curl "https://content.better-i18n.com/v1/content/your-org/your-project/models/help-article/entries?status=published" \ -H "x-api-key: YOUR_API_KEY" ``` ## आगे के कदम - [प्रविष्टियाँ बनाना और संपादित करना](/help/creating-and-editing-entries) - [कॉन्टेंट प्रविष्टियों का अनुवाद](/help/translating-content-entries) - [Content API और MCP संदर्भ](/help/content-api-and-mcp) - [कॉन्टेंट प्रविष्टियाँ प्रकाशित करना](/help/publishing-content) --- # What are the roles and permissions? Source: https://help.better-i18n.com/hi/help/team-and-account/roles-and-permissions Five roles, and the distinction that matters is between **Translator** and **Reviewer**: one can write a translation, the other can approve it. Everything else follows from that. ## The roles | Role | What it is for | |---|---| | **Owner** | Everything, including deleting the organization. One per org by default | | **Admin** | Org settings, billing, member management, all project and translation work | | **Developer** | GitHub sync, CDN upload, key import, project settings, API keys | | **Reviewer** | Translate, and approve other people's translations | | **Translator** | Translate only — cannot approve, including their own work | ## Permissions, precisely Access is checked per resource and action, so the table is not a summary — it is the rule: | | Owner | Admin | Developer | Reviewer | Translator | |---|---|---|---|---|---| | Read project | ✓ | ✓ | ✓ | ✓ | ✓ | | Update project settings | ✓ | ✓ | ✓ | — | — | | Create / delete project | ✓ | ✓ | — | — | — | | Read translations | ✓ | ✓ | ✓ | ✓ | ✓ | | Edit translations | ✓ | ✓ | ✓ | ✓ | ✓ | | Create / delete keys | ✓ | ✓ | ✓ | — | — | | **Approve translations** | ✓ | ✓ | ✓ | ✓ | **—** | | Create / delete API keys | ✓ | ✓ | ✓ | — | — | | Manage integrations | ✓ | ✓ | read only | read only | read only | | Manage members | ✓ | ✓ | — | — | — | | Billing | ✓ | ✓ | — | — | — | | Delete organization | ✓ | — | — | — | — | Two things people get wrong from that table: - **Developers can approve.** They have full translation control, not just key management. If you want a strict separation between who writes code and who signs off on wording, Developer is not the role for the first group. - **Translators cannot approve their own work either.** That is the point of the role — everything they do stays reviewable. ## Inviting someone 1. **Members** → **Invite member** 2. Enter the email 3. Pick the role — the dialog explains each one as you select it 4. Send **Admin can only be granted by an Owner.** An Admin inviting someone sees Developer, Reviewer, and Translator only. ## Changing a role later Click the member's role in the members list and pick another. It takes effect immediately — no re-invite. The same restriction applies: an Owner can assign any role including Owner; an Admin can assign Developer, Reviewer, or Translator. Nobody can change an Owner's role but the Owner, and an Admin cannot change another Admin. ## Which role for an outside translator **Translator.** They can read every key and its source text, edit translations, and use AI translation — and they cannot approve, delete keys, create API keys, or touch billing. Their output lands as unapproved and waits for someone with approve rights. If the same person is also the one who signs off, give them **Reviewer** instead, and accept that there is then no second pair of eyes. ## Related - [How do I invite team members?](/help/team-and-account/inviting-team-members) - [How do I review and approve translations?](/help/managing-translations/review-and-approve-translations) — what approve actually does --- # How does website analysis improve translations? Source: https://help.better-i18n.com/hi/help/ai-and-automation/website-analysis Website analysis reads your product's own site and works out how it talks — voice, tone, audience, the words you use for your own features — then hands that to the AI translator so it stops guessing. It is a **context** feature, not a scanner. It does not hunt for hardcoded strings in your code or invent key names; that is what `better-i18n scan` and the CLI's `check` commands are for. ## What it extracts From a crawl of your public pages: - **Brand voice and tone** — formal or casual, the person you address the reader as - **Product category and features** — so "board", "space" or "run" is translated as your product's noun rather than the dictionary's - **Target audience** — who the copy is written for - **Frameworks and technical terms** — the vocabulary that should stay in English - **Candidate glossary terms**, each typed as brand, technical, product, feature or UI ## Running an analysis 1. Open the project's AI context settings 2. Enter your website URL 3. Start the analysis Two speeds: | Mode | Behaviour | |---|---| | **Quick** | Runs inline and comes back approved or failed while you wait | | **Full** | Queued as a background job, plus a second **enrichment** pass that lands later and reports "+N terms added" | Full mode is the one to use for a real site. Quick mode exists so onboarding does not sit on a spinner. You can list previous jobs, check a running one's progress, and cancel one that is taking too long. ## Nothing lands without your approval An analysis produces a proposal, not a fact: you **approve** or **reject** it. That gate matters because the output goes on to shape every AI translation in the project — an analysis that read your marketing site during a rebrand should be rejected, not quietly adopted. ## Analysing a repository instead If your product's voice lives in a README rather than a landing page, point the analysis at the repository instead. It reads the same things — audience, terminology, frameworks — from the source you actually maintain. ## Where the results end up Extracted terms become **glossary** entries, tagged with where they came from: | Source | Meaning | |---|---| | `website_analysis` | Found by crawling your site | | `repo_analysis` | Found in your repository | | `manual` | You added it | So you can always see whether a term is something you decided or something a crawl inferred — and edit, delete, or clear them accordingly. Because the glossary is what gets enforced during translation, this is the step that turns "the AI read our site" into "the AI uses our words". ## Limitations - The crawler needs pages it can reach: a password-protected or staging-only site will not analyse - It reads what is on the page, so a site whose copy is thin gives thin context — a repo analysis is often better for developer tools - There is no scheduled re-scan. Re-run it when your product's language actually changes: a rebrand, a new surface, a renamed feature ## What it does not do To be explicit, since the name invites the assumption: - it does not find untranslated hardcoded strings — `better-i18n check:missing` does that - it does not create translation keys - it does not audit date, number or currency formatting --- # I published but my app still shows old translations Source: https://help.better-i18n.com/hi/help/troubleshooting/publish-not-updating-app Give it a minute first. The CDN caches translations for 60 seconds, and publishing purges what changed — so most reports of this turn out to be someone checking within twenty seconds. If it is still old after a minute, one command tells you which side to look at. ## Which side is stale ```bash curl -s https://cdn.better-i18n.com/your-org/your-project/manifest.json ``` Compare `files[].lastModified` with the time you published. **Older than your publish** → the publish did not reach the CDN. Go to "The publish did not land". **Newer** → the CDN has your new strings and your app is holding an old copy. Go to "Your app is holding a copy". The manifest is the right thing to check because it is the same file every SDK uses to decide whether to refetch. If it is current and your app disagrees, the disagreement is downstream. ## The publish did not land **Nothing was pending.** `better-i18n publish:status` lists what would go live. Empty means your edit was never staged for publishing — check you saved it, and that you are in the project you think you are. **The language is a draft.** Draft languages are excluded from publishing on purpose. The Languages page shows the status. **Wrong project.** Publishing from `acme/dashboard` while your app reads `acme/website` produces exactly this symptom, with no error anywhere. The identifier in the dashboard and the one in your config have to match character for character. ## Your app is holding a copy Work down the list — each layer has its own clock. **The SDK's own refresh interval.** In production the Next.js SDK refetches messages every **5 seconds** by default and the manifest every hour; in development both are 0, meaning every request. Raising `messagesRevalidateSeconds` for fewer requests also lengthens this window: ```typescript export default createI18n({ project: "your-org/your-project", messagesRevalidateSeconds: 5, }); ``` **Rendered output, not translations.** A statically rendered or ISR page contains the strings from when it was built. New translations cannot change an HTML file that is already generated — the page has to be regenerated. `export const revalidate = 30` bounds it; a rebuild ends it now. **Make publishing trigger the rebuild.** Instead of guessing at intervals, have Better i18n tell your app when to revalidate: ```typescript // app/api/i18n/revalidate/route.ts import { createRevalidateHandler } from "@better-i18n/next"; export const POST = createRevalidateHandler({ secret: process.env.BETTER_I18N_WEBHOOK_SECRET!, revalidatePaths: ["/"], }); ``` Point a webhook at it and a publish revalidates the pages you name, signature-verified. This is the only arrangement where "published" and "visible" are the same moment. See [How do I set up webhooks?](/help/developer-integration/webhooks-setup). **A CDN or proxy in front of your app.** Cloudflare, Fastly, Vercel's edge cache — they cache your rendered pages, and none of them know you published anything. Purge there too, or use the webhook above. **The browser.** Hard refresh (`Cmd+Shift+R` / `Ctrl+Shift+R`) before concluding anything, and check in a private window. A service worker will happily serve last week's bundle. ## Narrowing it in one pass ```bash # Is the CDN current? curl -s https://cdn.better-i18n.com/your-org/your-project/manifest.json # Is the string actually there? curl -s https://cdn.better-i18n.com/your-org/your-project/en/translations.json | grep -i "your string" # Is your app's response cached, and by whom? curl -I https://your-app.com/the-page ``` If the CDN has the string and your page does not, the answer is in the third command's headers — `age`, `x-vercel-cache`, `cf-cache-status`, or an `etag` that has not moved. ## Related - [My translations aren't showing: what do I check?](/help/troubleshooting/translations-not-showing) — when they never appeared at all - [Locale switching updates content but not UI translations (SSR)](/help/troubleshooting/locale-switch-ui-translations-stale-ssr) - [How do I set up webhooks?](/help/developer-integration/webhooks-setup) --- # How does AI translation work? Source: https://help.better-i18n.com/hi/help/managing-translations/translate-with-ai Better i18n translates with LLMs, and there is more than one path into them. They do not all carry the same context, which is the thing worth understanding before you judge the output. ## The three paths | Path | How you reach it | What the model is told | |---|---|---| | **AI drawer** | `⌘I` in the project | Your **glossary** and project context — the richest path | | **Cell suggestion** | The AI action on a single value | The text, the source and target language, and your project instructions | | **Bulk translation** | Translate many keys at once | Queued as a job and applied when it finishes | | **Your own agent** | MCP, from your editor | `getTranslationContext` gives it the same glossary and instructions the drawer uses | The practical consequence: **if terminology consistency matters, translate through the drawer or an MCP agent.** A one-off cell suggestion is a good translation of that string, but it is not looking at your glossary — so a pinned term can come back as a synonym, and you would have no idea why. ## What shapes the output - **Project instructions** (`Custom System Prompt`) are inserted ahead of the platform's own rules. Formality, regional variant, register — see [translation guidelines](/help/ai-and-automation/translation-guidelines). - **Glossary terms** reach the drawer and MCP paths, matched by meaning rather than exact substring — see [managing your glossary](/help/ai-and-automation/managing-glossary). - **Three rules are always applied**, whatever you write: return only the translation, keep the tone and style of the source, and **preserve placeholders** like `{{variable}}` and `{count}`. That last one matters more than it sounds. A translation that drops `{count}` is not a slightly worse translation, it is a broken string, and it is the failure mode a human reviewer skims past. ## Quality habits, in order of payoff 1. **Set the formality per language.** No model can infer whether your German says *Sie* or *du* from a button label, and getting it wrong is the mistake every native speaker notices first. 2. **Put the words you cannot afford to lose in the glossary,** not in a prompt paragraph. Per-term entries are reviewable and reach machine-translation engines too. 3. **Translate through the drawer for anything user-facing.** Cell suggestions are for filling a gap, not for setting your product's voice. 4. **Review what a customer reads closely** — pricing, legal, empty states, error messages. AI is fast at the long tail; the short list at the top is where a human pass pays for itself. 5. **Check the placeholders survived** before publishing. The editor highlights them, so this is a glance, not an audit. ## After translating Nothing is live until you publish. Each value keeps its own history with the origin of the change recorded — an AI-generated value says so — so a translation you are unsure about can always be traced rather than guessed at. - [Review and approve translations](/help/managing-translations/review-and-approve-translations) - [Publish translations so your app uses them](/help/getting-started/publish-translations-first-time) --- # How do I add new translation keys? Source: https://help.better-i18n.com/hi/help/getting-started/add-new-translation-keys Four ways, and the honest way to choose between them is by who is doing the work: you, an AI in the dashboard, an agent in your editor, or your own code. All of them work on every plan, including Free. ## Upload a JSON file Best when you already have the strings — migrating from another tool, or adding a batch someone sent you. 1. **Integrations → CDN** 2. **Upload**, pick your file 3. Pick the format it is in: **flat**, **nested**, or **namespaced** 4. **Publish** Uploading merges: existing keys keep their translations, new ones are added. It does not wipe what is there. Flat and nested are both accepted, so you do not have to reshape your file first: ```json { "homepage.title": "Welcome" } ``` ```json { "homepage": { "title": "Welcome" } } ``` ## Ask the AI in the dashboard Best when you do not have the strings yet — you know what the screen needs, not what the keys are called. Open the project, open the AI, and describe it: > I need onboarding strings: a welcome heading, a subtitle about what the platform does, a "get started" button, and a "skip" option. Translate to French and Spanish, then publish. It names the keys, writes the English, translates using your glossary and guidelines, and publishes — one pass. ![Better AI creating translation keys in the dashboard](https://s3.better-i18n.com/content/b0f7feb7-fc38-4bca-aa21-02d0a9a7009a/dd6b2778-ff7e-42b5-b123-a786726d22d4/1775033419738-ffayws.jpeg) The naming is the part worth outsourcing here. Keys invented in a hurry are the ones you regret in six months. ## Ask an agent in your editor Best when the key and the code that uses it should land together. With the MCP server connected, ask in the same words: > Add onboarding strings — welcome heading, description, get started button, skip. Translate to French and Spanish, then publish. The agent calls `createKeys`, writes the translations, and can `publishTranslations` when you tell it to. ![Claude Code creating translation keys via MCP](https://s3.better-i18n.com/content/b0f7feb7-fc38-4bca-aa21-02d0a9a7009a/dd6b2778-ff7e-42b5-b123-a786726d22d4/1775033433668-h8k2y0.jpeg) The advantage over the dashboard AI is not the typing — it is that the agent can add the key **and** use it in your component in the same edit, so the two cannot drift apart. Setup: [How do I use the MCP server with AI coding agents?](/help/ai-and-automation/mcp-server-for-agents) ## From your own code, with the CLI Best when the keys already exist in your source as `t()` calls. ```bash better-i18n sync # show what your code has that your project does not better-i18n sync --push # create them ``` **The CLI does push keys** — `sync --push` creates everything it found, and `keys create` adds them one at a time. Its read-only commands are the checks: ```bash better-i18n scan # hardcoded text that should be a key better-i18n check:missing # in code, not in your project better-i18n check:unused # in your project, not in code better-i18n doctor # placeholder mismatches, orphans ``` Setup: [How do I set up the CLI and scan my project?](/help/developer-integration/cli-quickstart) ## With GitHub sync connected If your repository is connected, new keys arriving in code are detected as part of the sync — no separate step. Translate them in the dashboard and publish. Setup: [How do I connect GitHub and sync translations?](/help/developer-integration/github-sync-setup) ## Then publish Nothing you add reaches your app until it is published, whichever method put it there. The dashboard's Publish button, `better-i18n publish`, or the agent doing it for you — and up to a minute for the CDN cache after that. ## Choosing | | When | Names the keys for you | Can publish | |---|---|---|---| | JSON upload | You have a file | No | After you click Publish | | Dashboard AI | You have a screen, not strings | Yes | Yes | | MCP agent | You are writing the code now | Yes | Yes | | CLI `sync --push` | The keys are in your code | No — your code named them | Separate command | | GitHub sync | Repo connected | No | From the dashboard | ## Related - [How do I add my first translation keys?](/help/getting-started/add-your-first-translations) - [How do I set up CDN delivery?](/help/developer-integration/cdn-delivery-setup) - [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app) --- # मैं अपना पहला प्रोजेक्ट कैसे बनाऊँ? Source: https://help.better-i18n.com/hi/help/getting-started/create-your-first-project एक प्रोजेक्ट किसी एक ऐप या साइट की की-ज़, भाषाएँ और सेटिंग्स रखता है। इसे बनाने में एक मिनट लगता है; जो दो चीज़ें सही होनी चाहिए वे हैं स्रोत भाषा और यह कि आपकी कुंजी कहाँ रखी जाती है। ## इसे बनाएँ 1. [app.better-i18n.com](https://app.better-i18n.com) पर साइन इन करें 2. **New Project** 3. नाम दें — यही नाम प्रोजेक्ट का slug बनता है, इसलिए छोटा रखें 4. **स्रोत भाषा** चुनें: वह भाषा जिसमें आपका कोड लिखा है 5. जिन भाषाओं में अनुवाद चाहिए, उन्हें जोड़ें 6. **Create** ![अपना प्रोजेक्ट बनाएँ — नाम दर्ज करें और स्रोत भाषा चुनें](https://s3.better-i18n.com/content/help/screenshots/create-your-first-project/new-project-modal.png) **सोच-समझकर लेने लायक एकमात्र निर्णय स्रोत भाषा है।** हर अनुवाद उसी से बनता है और हर की का मूल टेक्स्ट उसी में रहता है। आप इसे बाद में **Settings → CDN** से बदल सकते हैं, पर बाकी सब कुछ इसी के सापेक्ष तय होता है — इसलिए उसी भाषा से शुरू करें जिसमें आप वास्तव में लिखते हैं। लक्ष्य भाषाएँ बदलना आसान है — Languages पेज से जब चाहें जोड़ें या हटाएँ। ## आपका प्रोजेक्ट पहचानकर्ता हर प्रोजेक्ट `org/project` के रूप में संबोधित होता है — `acme/dashboard`। आप इसे SDK कॉन्फ़िग, CLI कॉन्फ़िग और हर API कॉल में लिखेंगे। ## API कुंजी बनाएँ **Settings → API Keys → Create Key.** ![Create API Key — अपनी कुंजी को नाम दें और Create Key पर क्लिक करें](https://s3.better-i18n.com/content/help/screenshots/create-your-first-project/create-api-key-modal.png) ऐसा नाम दें जो छह महीने बाद भी पहचान में आए ("CI", "local dev"), और एक अवधि चुनें: **3 महीने, 6 महीने, 1 साल, या कभी नहीं**। सूची दिखाती है कि हर कुंजी कितनी बची है, और समाप्ति में एक महीना रह जाने पर चेतावनी देती है — CI में चुपचाप मर जाने वाली कुंजी एक खराब दोपहर बन जाती है। **कुंजी केवल एक बार दिखती है।** दिखते ही कॉपी कर लें; इसे दोबारा पढ़ा नहीं जा सकता, केवल बदला जा सकता है। ## कौन-सी कुंजी कहाँ | | पढ़ना | लिखना | ब्राउज़र में सुरक्षित | |---|---|---|---| | **CDN / डिलीवरी कुंजी** | ✓ | — | ✓ | | **API कुंजी** | ✓ | ✓ | **नहीं** | CDN कुंजी केवल प्रकाशित अनुवाद लाती है, जो वैसे भी सार्वजनिक डेटा है और आपके आगंतुक उसे डाउनलोड करते ही हैं। API कुंजी आपका प्रोजेक्ट बदल सकती है, इसलिए उसकी जगह सर्वर एनवायरनमेंट वेरिएबल या CI सीक्रेट है — कभी क्लाइंट-साइड कोड में नहीं, और कभी कमिट की गई फ़ाइल में नहीं। अगर कोई गुप्त कुंजी सार्वजनिक जगह पहुँच जाए: API Keys पेज से उसे रद्द करें और नई बनाएँ। रद्द करना तुरंत प्रभावी होता है। ## अपने ऐप को इससे जोड़ें ```bash # .env BETTER_I18N_PROJECT=acme/dashboard BETTER_I18N_API_KEY=... ``` ```typescript // सटीक कॉल आपके फ़्रेमवर्क पर निर्भर करता है — SDK गाइड देखें const i18n = createI18n({ project: "acme/dashboard", defaultLocale: "en", }); ``` ## आगे क्या - [अपनी पहली ट्रांसलेशन की-ज़ कैसे जोड़ूँ?](/help/add-your-first-translations) - [CLI कैसे सेट करूँ और अपना प्रोजेक्ट कैसे स्कैन करूँ?](/help/cli-quickstart) - [अनुवाद कैसे प्रकाशित करूँ ताकि मेरा ऐप उन्हें इस्तेमाल करे?](/help/publish-translations-first-time) --- # How do I set up Better i18n with Next.js? Source: https://help.better-i18n.com/hi/help/developer-integration/nextjs-setup `@better-i18n/next` integrates with **next-intl**: it supplies the request config, the middleware and the CDN fetching, and you keep using standard `next-intl` hooks in your components. ## Prerequisites - Next.js App Router - A project at [dash.better-i18n.com](https://dash.better-i18n.com) — its identifier is `org/project` ## Step 1: Install ```bash npm install @better-i18n/next # or: bun add @better-i18n/next ``` ## Step 2: Create `i18n.config.ts` At the project root, because the CLI reads the same file: ```typescript import { createI18n } from "@better-i18n/next"; export const i18n = createI18n({ projectId: "my-company/web-app", defaultLocale: "en", }); ``` `createI18n` returns an object — `requestConfig`, `betterMiddleware`, `getMessages`, `getLocales`, `getManifest`, `proxy` — so keep it whole as `i18n` rather than destructuring hooks out of it. The translation hooks come from `next-intl`, not from here. You do not list your locales: they come from the project's manifest on the CDN, so adding a language in the dashboard does not mean editing this file. ## Step 3: Wire up next-intl ```typescript // src/i18n/request.ts import { i18n } from "../i18n.config"; export default i18n.requestConfig; ``` That is the whole server-side setup — no provider to mount in `layout.tsx` and no `getMessages` call to write by hand. ## Step 4: Add the middleware ```typescript // middleware.ts import { i18n } from "./i18n.config"; export default i18n.betterMiddleware(); export const config = { matcher: ["/((?!api|_next|.*\\..*).*)"], }; ``` Locale detection (cookie, then browser language) is built in, so there is no `detectLocale` of your own to write. If you have auth, pass a callback and keep both behaviours: ```typescript export default i18n.betterMiddleware(async (request, { locale }) => { const isLoggedIn = !!request.cookies.get("session")?.value; if (!isLoggedIn && request.nextUrl.pathname.includes("/dashboard")) { return NextResponse.redirect(new URL(`/${locale}/login`, request.url)); } // Return nothing and the i18n response is used, headers intact. }); ``` Returning nothing from the callback is the important half: that is how the i18n response — and the locale headers on it — survives your auth check. ## Step 5: Use translations ```tsx // app/page.tsx import { useTranslations } from "next-intl"; export default function Home() { const t = useTranslations("common"); return

{t("welcome")}

; } ``` Standard `next-intl` hooks, which is also what makes `better-i18n scan` able to find your keys — it looks for these calls. ## Revalidation (optional) Two windows, both Next.js ISR revalidation rather than an in-memory cache: | Option | Production default | Dev default | |---|---|---| | `messagesRevalidateSeconds` | 5 | 0 | | `manifestRevalidateSeconds` | 3600 | 0 | ```typescript export const i18n = createI18n({ projectId: "my-company/web-app", defaultLocale: "en", messagesRevalidateSeconds: 30, }); ``` Zero in development is deliberate: an edit shows up on the next request while you work, and production still caches. ## Next steps - [Add your first translation keys](/help/getting-started/add-your-first-translations) - [Translate with AI](/help/managing-translations/translate-with-ai) - [Publish translations](/help/getting-started/publish-translations-first-time) --- # Locale switching updates content but not UI translations (SSR) Source: https://help.better-i18n.com/hi/help/troubleshooting/locale-switch-ui-translations-stale-ssr The symptom is specific and it points at one thing: your page content changed language, so routing and the locale cookie are working, but the `t()` strings did not — so the messages your components are reading were never refetched. That is usually not a cache. It is which locale-switching mode you are in. ## The two modes, and why it matters `useSetLocale()` behaves differently depending on whether `BetterI18nProvider` is above it in the tree: | | What happens on switch | |---|---| | **With `BetterI18nProvider`** | Cookie is set, new messages are fetched on the client, the tree re-renders — instantly, no server round trip | | **Without it (standalone)** | Cookie is set, then `router.refresh()` or a navigation — the **server** produces the new strings | Both work. They fail differently, and the symptom in this article's title is what the second one looks like when the refresh does not actually re-render on the server. **Check first:** is `BetterI18nProvider` wrapping the part of the tree whose strings are stale? If it is not, you are in standalone mode and every locale switch depends on the server producing a fresh render — see below. ## If you are in standalone mode `router.refresh()` re-renders on the server, but it cannot get past a cached render. Anything that caches the rendered output will serve the old locale's HTML: - A statically rendered or ISR page - Your own CDN in front of Next.js - `force-cache` on a fetch in the render path The fix is to make the page dynamic for the routes that show translated UI, or to bound its revalidation. If a page is cached for an hour, its strings are an hour old — Better i18n has no way to reach into it. ## The fastest way to switch modes If instant switching is what you want, mount the provider: ```tsx // app/layout.tsx import { BetterI18nProvider } from "@better-i18n/next/client"; export default async function RootLayout({ children }) { const locale = await getLocale(); const messages = await getMessages(); return ( {children} ); } ``` Now `useSetLocale()` fetches the new language's messages from the CDN in the browser and re-renders. No server, no cache in the way, and the cookie still persists the choice for the next request. ## The explicit-choice cookie `useSetLocale()` writes two cookies: the locale, and a marker saying the visitor **chose** it. The middleware reads that marker so a deliberate choice is not overridden by `Accept-Language` on the next request. If your switcher sets the locale cookie by hand instead of calling `useSetLocale()`, you get the classic version of this bug: the switch works, and one navigation later the browser's language wins and it silently switches back. Use the hook, or write both cookies. ## When it really is stale translations If the language is right but the *wording* is old, that is a different problem — the strings are cached somewhere between the CDN and the screen: ```bash curl -s https://cdn.better-i18n.com/your-org/your-project/manifest.json ``` Newer than your publish means your app is holding a copy. [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app) works through the layers. ## Checklist 1. Is `BetterI18nProvider` above the stale components? 2. If not, is the page cached or statically rendered? 3. Is your switcher calling `useSetLocale()`, or writing cookies itself? 4. Does the CDN manifest actually have your latest publish? ## Related - [How do I set up Better i18n with Next.js?](/help/developer-integration/nextjs-setup) - [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app) --- # How do I create and edit content entries? Source: https://help.better-i18n.com/hi/help/content-management/creating-and-editing-entries Content entries are the individual pieces of content in your CMS — like a single blog post, help article, or product description. Here's how to create and edit them. ## Creating a new entry ### Via the dashboard 1. Go to your project's **Content** tab 2. Select the content model (e.g., "Help Article") 3. Click **"New entry"** 4. Fill in the title and body 5. Add values for any custom fields 6. Click **Save** (saves as draft) or **Publish** ### Via MCP (AI agents) Writing content goes through the dashboard or MCP — the Content API at `content.better-i18n.com` is read-only, so there is no `POST` to create an entry with. An agent calls `createContentEntry`: ```json { "modelSlug": "help-article", "title": "How to get started", "bodyMarkdown": "## Overview\n\nWelcome...", "customFields": { "category": "setup", "order": 1 } } ``` ### Creating with multiple languages at once Include all translations in the **initial** create call — creating first and then looping one update per language costs an extra call per language for the same result: ```json { "modelSlug": "help-article", "title": "How to get started", "bodyMarkdown": "## Overview\n\n...", "translations": { "tr": { "title": "Nasıl başlanır", "bodyMarkdown": "## Genel Bakış\n\n..." }, "fr": { "title": "Comment démarrer", "bodyMarkdown": "## Présentation\n\n..." } } } ``` For many entries at once, `bulkCreateEntries` takes up to 200 per call and reports partial failures in a `failed` array. ## Editing an existing entry ### Via the dashboard 1. Click the entry in the content list 2. Edit the title, body, or custom fields 3. Use the language switcher to edit each language 4. Click **Save** or **Publish** ### Via MCP `updateContentEntry` with the entry id and the fields you want to change. Only what you pass is touched. **Editing a published entry edits the live content.** There is no draft copy sitting in front of a published one: the save writes through and the Content API cache is purged, so the next request serves your edit. If the model has version history enabled, each save snapshots the previous revision per language, which is what you fall back on — not an unpublished staging copy. When you want to work on something without it being live, keep the entry itself in `draft` until it is ready. ## Markdown body guidelines - **Do NOT start with `# H1`** — the entry title is already rendered as the page H1 - Start your body with a paragraph or `## H2` section - Tables, code blocks, and images are all supported - Keep headings hierarchical (H2 → H3, not H2 → H4) ## Slugs Each entry has a slug — the URL-safe identifier used in API calls and content paths. By default it's generated from the title, and you can override it in the entry settings. One thing to know before you build URLs on it: the entry slug is a single CMS identifier shared by every language. If you need a different URL per language, add a localized custom field (for example `localized_slug`) and set it per language. ## Publishing vs. drafts - **Draft** — visible in the dashboard, `publishedAt` not stamped - **Published** — marked live, `publishedAt` stamped, cache purged Note that the Content API does not filter by status unless you ask it to. A request without `?status=published` returns drafts alongside live entries, so add the filter in the app that reads them. See [How do I publish content entries?](/help/content-management/publishing-content). ## Deleting entries Deleting an entry — from the dashboard's three-dot menu, or by an agent calling `deleteContentEntry` — moves it to **Trash**. It stops being served, and it can be restored (that is what the Undo action does). Permanently removing an entry is a separate action, and that one is irreversible: it takes the translations, field values and version history with it. --- # How does billing and pricing work? Source: https://help.better-i18n.com/hi/help/team-and-account/billing-and-plans Three plans — **Free**, **Pro**, **Enterprise** — billed per organization, not per project or per seat. Team members do not cost extra. ## What each plan allows | | Free | Pro | Enterprise | |---|---|---|---| | Projects | 5 | 10 | Unlimited | | Translation keys | 5,000 | 50,000 | Unlimited | | Content items | 100 | 10,000 | Unlimited | | AI messages / month | 500 | 5,000 | Unlimited | | Publishes / month | 100 | 1,000 | Unlimited | | MCP writes / month | 500 | 10,000 | Unlimited | | MCP reads / month | 5,000 | 100,000 | Unlimited | | Web research / month | 30 | 50 | Unlimited | Prices and the current limits are on the pricing page; this table is what the platform enforces. ## Two kinds of limit, and they behave differently This is the part that matters when you hit one: **Totals** — projects, translation keys, content items. These count what exists right now, across the organization. At the limit, **creating the next one is blocked** until you upgrade or delete something. What already exists keeps working: translations stay live, the CDN keeps serving. **Monthly usage** — AI messages, publishes, MCP reads and writes, web research. These reset each month and are **soft-capped**: you get a warning banner in the dashboard and an email as you approach the limit, rather than a door slamming mid-task. "Publishes" counts every publish, whether it came from the dashboard button or an AI agent over MCP. One number, both routes. ## Watching your usage **Settings → Billing** leads with your current plan and its usage — the four numbers most likely to matter, with the rest behind **Show all**. A metric turns to a warning at 80% of its limit, which is the point at which it is worth deciding rather than discovering. Keys in deleted projects do not count. Neither do deleted keys. ## Upgrading **Settings → Billing → Upgrade to Pro**, then Stripe Checkout. **Pro includes a free trial** — the modal tells you how many days before you commit a card, and billing starts when the trial ends. Yearly or monthly: the toggle is in the upgrade modal, and yearly is preselected because it is cheaper. ## Changing or cancelling later Everything after the first purchase goes through the **Stripe customer portal**, reachable from the billing page: change your card, download invoices, switch cycle, or cancel. Cancelling leaves you on Pro until the end of the period you paid for, then drops to Free. Anything over the Free limits stops being creatable at that point — nothing is deleted, but you will not be able to add the 5,001st key until you are back under. ## Enterprise Unlimited on every metric, and the conversation is about what else you need — invoicing, custom terms, support commitments. Ask through the chat widget on this site and it will reach us. ## Related - [How do I invite team members?](/help/team-and-account/inviting-team-members) — seats are not billed - [How do I access content via API and MCP?](/help/content-management/content-api-and-mcp) — what counts as an MCP call --- # How do I set up translation guidelines? Source: https://help.better-i18n.com/hi/help/ai-and-automation/translation-guidelines Translation guidelines are free-form instructions that travel with every AI translation in the project: tone, formality, regional variant, anything specific to how your product should sound. ## Where they live The project's **AI Context** settings, under **Custom System Prompt**. One text field, project-wide. What happens with it: your text is inserted into the translator's system prompt as `PROJECT-SPECIFIC INSTRUCTIONS`, ahead of the platform's own rules. So it is read before the model decides anything — not applied as a filter afterwards. ## Don't spend instructions on what is already enforced Three rules are always in the prompt, whatever you write: - return only the translation, no commentary - keep the tone and style of the source - **preserve placeholders and variables** — `{{variable}}`, `{count}` and friends Repeating those wastes the part of the prompt where your product's actual particulars should be. ## What is worth writing ### Formality ``` German: use "Sie", never "du". French: use "vous" in all user-facing text. Turkish: use "sen" — our Turkish users prefer the informal register. ``` Formality is the single highest-value instruction, because it is a decision no model can infer from a UI string and it is wrong in a way every native speaker notices immediately. ### Regional variant ``` Spanish: Latin American Spanish, not Castilian. Chinese: Simplified. Portuguese: Brazilian. ``` ### What stays in English ``` Keep "API", "SDK", "token", "webhook" in English in every language. Never translate the product name. ``` For a handful of terms this is fine here. For a real vocabulary, use the [glossary](/help/ai-and-automation/managing-glossary) instead — it is per-term, reviewable, and gets synced to machine-translation engines, which prose in a prompt does not. ### Length pressure ``` Button and menu labels: as short as the target language allows, even if that means dropping an article the source has. ``` Worth saying because a translation that is technically right and 40% longer breaks the layout it lands in. ## One field, not per-language sections There is no structured per-language setting: writing `[Turkish]` as a header does not create a Turkish-only rule, it is just text in the same instruction block. Naming languages inline (as above) works because the model reads it, not because the platform parses it. If your languages need genuinely different treatment, keep the instructions short and explicit per language rather than long paragraphs — the model is choosing between them on every string. ## Where else your instructions show up - **Activity log** — a change is recorded with its previous and new value, so a translation quality shift can be traced to the day someone rewrote the prompt - **AI agents** — `getTranslationContext` hands your instructions to an MCP client along with the glossary, so an agent translating from your editor follows the same rules as the dashboard ## Guidelines vs glossary | | Glossary | Guidelines | |---|---|---| | **Shape** | One entry per term, typed and described | One free-form text field | | **Best for** | "This word is translated exactly this way" | "This is how we sound" | | **Matching** | Semantic retrieval per string | Always present in the prompt | | **Reaches MT engines** | Yes, synced to the provider | No — AI translation only | | **Reviewable** | Per term, with a source and history | As one blob | Use both: the glossary for words you cannot afford to get wrong, guidelines for the register everything is written in. --- # How do I manage languages and track coverage? Source: https://help.better-i18n.com/hi/help/managing-translations/managing-languages Languages is a page in your project, next to Translations — not a settings screen. It answers two questions: which languages ship, and how far each one has got. ## The three states a language can be in This is the part worth knowing before anything else, because it is how you work on a language without shipping it. | Status | In the editor | On the CDN | |---|---|---| | **Active** | Visible | Published | | **Draft** | Visible | **Excluded** | | **Archived** | Hidden | Excluded | **Draft** is for a language you are still filling in: your team can translate it in the editor, and publishing skips it. Switch it to Active when it is ready, and the next publish picks it up. **Archived** is the reversible way to stop shipping a language. Nothing is deleted — the translations stay, the language just leaves the editor and the CDN. If you think you might want a language back, archive it instead of deleting it. ## Adding languages 1. Open **Languages** in your project 2. Click **Add language** — the **Manage languages** dialog opens 3. Search by code, name, or native name (languages are grouped by region) 4. Select as many as you want, then save You can add several at once, and you can set each one to Active or Draft as you add it. Existing keys show as missing for a new language until something fills them. ## Reading the table | Column | What it tells you | |---|---| | Coverage | Percentage of keys with a translation | | Translated | How many keys are done | | Missing | How many are not | Above the table: overall coverage across the project, total keys, total missing, and how many languages need attention. Sort by **Coverage** to see the weakest language first, by **Missing** for the biggest backlog, alphabetically, or drag the rows into your own order — a custom order is saved for the project, including where the source language sits. ## Removing a language Removing deletes that language's translations. The confirmation tells you exactly how many are about to go: > 1,248 translations will also be deleted. This action cannot be undone. If it has none, it says so instead. There is no undo and no export step in the dialog, so if the translations took work, **archive instead** — same effect on your app, nothing lost. ## Changing the source language The source language is the one your code is written in, and every translation is produced from it. It lives in **Settings → CDN**, not on the Languages page: 1. **Settings → CDN** 2. **Source Language** → pick the language 3. **Save** ## What your app gets for a missing translation By default, nothing — the key is absent from that language's CDN payload and your SDK falls back on its own. **Settings → CDN → CDN Fallback** changes that: missing translations are filled with the source-language value. Your app then always receives a string, at the cost of showing English (or whatever your source is) where a translation is not ready yet. Which one you want depends on whether a visible English string is better or worse than a visible key. ## Language codes Codes are IETF BCP 47 — `en`, `tr`, `fr`, `de`, `ja` for plain languages, and a region or script where it matters: `pt-BR`, `zh-Hans`, `zh-Hant`. The code is what your SDK asks the CDN for, so it has to match the locale your app uses. Search the Manage languages dialog by code if you are not sure which variant you want. ## Right-to-left languages Better i18n stores a text direction for every language, so Arabic, Hebrew, and Persian are known to be RTL rather than guessed from the code. Applying it is your app's job: set `dir="rtl"` on the document when an RTL locale is active. The SDK returns strings, not layout — nothing about the CDN payload flips your CSS for you. --- # How do I set up CDN delivery? Source: https://help.better-i18n.com/hi/help/developer-integration/cdn-delivery-setup The CDN delivers your translations globally with low latency. This guide covers the URL shape, the cache behaviour, and how the SDK fetches from it. ## How CDN delivery works ``` You publish translations → JSON files written to R2 origin storage → Edge cache purged for the affected paths → A user requests a translation file → The nearest edge serves it (X-Cache-Status: HIT) ``` Translations are served as JSON from `https://cdn.better-i18n.com`. CDN delivery is on by default — your SDK already points at it, there is nothing to enable. ## CDN URL format ``` https://cdn.better-i18n.com/{org}/{project}/manifest.json https://cdn.better-i18n.com/{org}/{project}/{locale}/{namespace}.json https://cdn.better-i18n.com/{org}/{project}/{locale}/batch.json ``` Most projects have a single file per locale, and it is called `translations.json`: ``` https://cdn.better-i18n.com/acme/dashboard/en/translations.json https://cdn.better-i18n.com/acme/dashboard/tr/translations.json ``` If your project keeps its keys in namespaces, each namespace becomes its own file — `en/common.json`, `en/dashboard.json` — and `batch.json` returns several of them in one request, which is what the SDK uses to avoid a fetch per namespace. The same paths work with the project UUID in place of `{org}/{project}`, which is what the SDK falls back to if a slug changes under it. Verify a project by hand: ```bash curl -i https://cdn.better-i18n.com/your-org/your-project/en/translations.json ``` ## Renaming a project doesn't break live apps When a project slug changes, a redirect marker is written at the old path. Requests to the old CDN URL answer `301` to the new one for **30 days**, so a deployed app keeps working until you ship the new slug. After that window the old path is gone — treat the 30 days as a migration budget, not a permanent alias. ## Cache headers What the edge actually returns: | Path | `Cache-Control` | |---|---| | `{locale}/{namespace}.json` | `public, max-age=3600` | | `{locale}/batch.json` | `public, max-age=60, s-maxage=60` | Every response carries `X-Cache-Status: HIT` or `MISS`. There is no `stale-while-revalidate` on these responses, and that is deliberate rather than an omission: Cloudflare's Cache API does not hand back a stale entry once `max-age` has passed, so advertising SWR would describe a behaviour you would not get. Freshness comes from the purge on publish, not from a revalidation window. ## SDK fetch behaviour In `@better-i18n/next` the two knobs are Next.js ISR revalidation windows, not an in-memory TTL: | Option | Default (production) | Default (dev) | |---|---|---| | `messagesRevalidateSeconds` | 5 | 0 | | `manifestRevalidateSeconds` | 3600 | 0 | ```typescript export const i18n = createI18n({ project: "acme/dashboard", messagesRevalidateSeconds: 30, }); ``` Zero in development is on purpose — you want an edit to show up on the next request while you are working, and you want caching in production. ## Monitoring Per-project CDN metrics — request volume and cache hit rate — are on the project's **overview** page. Platform-wide CDN status is at [status.better-i18n.com](https://status.better-i18n.com), which you can subscribe to for outage notifications. ## Serving from your own domain Not self-serve today. If a strict CSP means you cannot call `cdn.better-i18n.com`, talk to support rather than proxying it yourself — a proxy in front of the CDN usually ends up caching translations twice with two different lifetimes, which is worse than the problem it solves. --- # How do I add my first translation keys? Source: https://help.better-i18n.com/hi/help/getting-started/add-your-first-translations Three ways in, and the difference between them is who is doing the typing: the CLI reads your code, the dashboard takes them from you, an AI agent writes both the key and the code. ## From your code, with the CLI This is the one to start with, because your keys already exist — they are the `t()` calls you wrote. ```bash bun add -g @better-i18n/cli better-i18n login better-i18n sync ``` `sync` compares what is in your code with what is in your project and shows you the difference. It changes nothing until you say so: ```bash better-i18n sync --push # create the missing keys better-i18n sync --push --yes # no confirmation prompt better-i18n sync --summary # just the numbers ``` `--push` only ever adds keys found in code. Nothing in your project gets deleted by a sync. ## Finding text you have not wrapped yet `sync` compares keys. `scan` finds the strings that never became keys: ```bash better-i18n scan # hardcoded text, reported like lint output better-i18n scan --fix # wrap it in t() for you better-i18n scan --staged # only what you are about to commit better-i18n scan --ci # non-zero exit if anything is found ``` `--fix` edits your files. Run it on a clean tree so the diff is reviewable. `--staged` in a pre-commit hook is the version that keeps a codebase clean without anyone having to remember. ## By hand, in the dashboard For a key that has no code yet — a string for a page you are still designing, or a value someone dictated in a meeting: 1. Open the project's translation editor 2. **Add key** 3. Type the key, pick or type its namespace, and give the source value ![Translation editor — keys organized by namespace with language columns](https://s3.better-i18n.com/content/help/screenshots/add-your-first-translations/translations-editor.png) The Add-keys dialog also takes a paste and an upload, so an existing JSON file or a spreadsheet column becomes keys in one step instead of forty. ## With an AI agent If you use Claude Code, Cursor, or anything else that speaks MCP, connect the Better i18n MCP server and ask in words: ``` Add common.welcome_message with the English value "Welcome back!" ``` The agent calls `createKeys`. The reason to want this is not the typing it saves — it is that the agent can add the key **and** use it in the code in the same edit, so the two never drift. See [How do I use the MCP server with AI coding agents?](/help/ai-and-automation/mcp-server-for-agents). ## Naming The part before the first dot is the namespace, and the namespace is what your app can load on its own — so it is a loading decision, not just tidiness. A checkout page that only needs `checkout` should not be downloading `admin`. | Shape | Example | For | |---|---|---| | `namespace.thing` | `common.save` | Reused across the app | | `namespace.section.thing` | `settings.profile.title` | One screen | | `namespace.action` | `auth.login_button` | One feature | Where a string is ambiguous on its own — "Open", "Post", "Set" — the key is not enough context for a translator, and neither is the source text. Say where it appears. Better i18n also reads your live pages for context, which is [website analysis](/help/ai-and-automation/website-analysis). ## Check the two directions ```bash better-i18n check:missing # in code, not in your project better-i18n check:unused # in your project, not found in code better-i18n check # both, interactively ``` `check:missing` catches the key you used but never created — the one that ships as a raw key path. `check:unused` finds what a refactor left behind. Both take `--ci` style output via `--format json`, which is how they end up in a pipeline. ## Next - [How does AI translation work?](/help/managing-translations/translate-with-ai) - [How do I publish translations so my app uses them?](/help/getting-started/publish-translations-first-time) --- # How do I translate content entries? Source: https://help.better-i18n.com/hi/help/content-management/translating-content-entries Content entries can have translations in multiple languages. This guide covers how to add and manage those translations. ## Yes, this covers your help centre "Content entry" is the generic name; what people usually mean is one of these, and all of them translate the same way: - A **help centre** or **knowledge base** — one entry per article - A **docs site** — one entry per page - A **blog** — one entry per post - **Marketing pages** — landing pages, feature pages, pricing copy - **Product descriptions**, changelog entries, email templates If your help centre content already lives somewhere else, it has to become content entries here first — through the dashboard, the [content API](https://help.better-i18n.com/help/content-management/content-api-and-mcp), or an AI agent over MCP. Once an entry exists, everything below applies to it. What this is *not*: UI strings. Buttons, labels, validation messages and navigation are [translation keys](https://help.better-i18n.com/help/getting-started/add-new-translation-keys), a different surface with a different editor. The [Content CMS overview](https://help.better-i18n.com/help/content-management/what-is-content-cms) explains where the line falls. ## Adding a translation to an existing entry ### Via the dashboard 1. Open the content entry 2. Click **"Add translation"** in the top-right language switcher 3. Select the target language 4. Enter the translated title and body 5. Click **Save** ### Via MCP (AI agents) ``` "Translate entry {id} to Turkish and French" ``` The agent calls `updateContentEntry` with a `translations` map: ```json { "entryId": "uuid", "translations": { "tr": { "title": "Başlık", "bodyMarkdown": "## Bölüm\n\nİçerik..." }, "fr": { "title": "Titre", "bodyMarkdown": "## Section\n\nContenu..." } } } ``` Writing translations goes through the dashboard or MCP. The Content API at `content.better-i18n.com` is **read-only** — it has no `PATCH` or `POST` route. ## Bulk translating entries For multiple entries at once, use `bulkUpdateEntries` — up to **200 entries per call**, with a `failed` array in the response so only the failures need retrying: ```json { "entries": [ { "entryId": "uuid-1", "translations": { "tr": { "title": "...", "bodyMarkdown": "..." }, "fr": { "title": "...", "bodyMarkdown": "..." } } }, { "entryId": "uuid-2", "translations": { "tr": { "title": "..." } } } ] } ``` One practical note from doing this at scale: a very large payload can be truncated in transit, and a truncated JSON body fails to parse rather than failing loudly per entry. If a bulk call errors out on a large batch, split it — fewer entries, or fewer languages per call — rather than retrying the same payload. ## Translation status Each translation carries its own status: | Status | Meaning | |---|---| | `draft` | Saved, not marked live | | `published` | Marked live, `publishedAt` stamped | | `archived` | Taken out of circulation, content kept | The Content API does **not** filter by status on its own, so a request without `?status=published` returns drafts and archived entries too. If your app renders whatever the API hands back, add the filter. Publishing an entry publishes its translations — but not blindly. From the dashboard you can pick which languages go live, and a language whose body is still empty is skipped rather than being marked published over a blank editor. Over MCP there is no per-language choice: every language with content goes live. See [How do I publish content entries?](/help/content-management/publishing-content). ## Finding untranslated entries Over MCP, use the `missingLanguage` filter: ``` listContentEntries({ modelSlug: "help-article", missingLanguage: "tr" }) ``` This returns entries that do NOT yet have a Turkish translation. > **Important:** `missingLanguage=tr` is the one you want, not `language=tr`. `language=tr` returns entries that already **have** Turkish — the exact opposite of what you are looking for when hunting down untranslated content. This filter is an MCP filter; the read API does not expose it. ## Localized custom fields Custom fields can be marked as `localized: true`, which means each language has its own value. Example: a `slug` field that stores the URL slug per language: ```json { "translations": { "en": { "customFields": { "slug": "getting-started" } }, "tr": { "customFields": { "slug": "baslarken" } }, "fr": { "customFields": { "slug": "premiers-pas" } } } } ``` Note the difference between the two slugs on an entry: the top-level `slug` is one shared CMS identifier across every language, so per-language URLs need their own localized field like the one above. Setting custom fields at the top level of an update touches the **source language only**. To set them for one language, pass them under that language — `translations.tr.customFields` — or use the `languageCode` single-language mode. ## Publishing translations Use the **Publish** button in the dashboard, or ask an agent to publish the entry (`publishContentEntry`). There is no publish endpoint on the read API. --- # How do I use i18n Doctor to check translation health? Source: https://help.better-i18n.com/hi/help/troubleshooting/using-i18n-doctor Doctor gives your project a health score out of 100 and tells you what is dragging it down. It runs from the CLI, and it can send the result to your dashboard so the score has a history instead of scrolling past in a terminal. ## Run it ```bash better-i18n doctor ``` It does three passes: | Pass | Looks at | Skip with | |---|---|---| | Code analysis | Your source, via AST — which keys you actually use | `--skip-code` | | File health | Your translation files — placeholders, structure | `--skip-health` | | Remote comparison | Your project on the CDN versus local | `--skip-sync` | Skipping is useful when one pass is slow or noisy — a monorepo where the AST walk takes a while, or a machine with no network. ## What it catches **Missing translations** — keys with no value in a language. **Orphan keys** — keys in your project that no code references. Not automatically wrong: dynamically built key names are invisible to a static scan, so read the list before deleting anything. **Placeholder mismatches** — the check worth having. If English says `{count}` and Turkish dropped it, the words look fine and the string is broken. Reviewers skim past this; a diff of placeholder sets does not. ## In CI ```bash better-i18n doctor --ci ``` `--ci` exits non-zero when the **health score** falls below its threshold — it is a score gate, not a "fail if anything is missing" flag. That is the difference that matters in a pipeline: one newly added untranslated key does not block a deploy, a project sliding backwards does. ```yaml - name: i18n health run: better-i18n doctor --ci env: BETTER_I18N_API_KEY: ${{ secrets.BETTER_I18N_API_KEY }} ``` JSON output for anything that needs to parse it: ```bash better-i18n doctor --format json ``` ## Send the report to your dashboard ```bash better-i18n doctor --report ``` That uploads the run to **Integrations → Doctor**, where you get the score, the four dimensions behind it — coverage, quality, structure, code — and a log of previous reports. The reason to bother: a number in your terminal tells you today's state, a series tells you which direction you are going. Run it in CI with `--report` and the trend appears without anyone maintaining it. ## Fixing what it finds | Finding | Fix | |---|---| | Keys in code, not in your project | `better-i18n sync --push` | | Missing translations | Editor's **Only missing** filter, or bulk AI translation | | Placeholder mismatch | Edit the value — the editor highlights placeholders, so it is visible | | Orphan keys | Check for dynamic usage first, then delete in the editor | There is no `delete-unused` command and no `doctor` block in your config: the options above are the whole surface, which keeps the report and the flags honest about each other. ## Related - [How do I set up the CLI and scan my project?](/help/developer-integration/cli-quickstart) - [My translations aren't showing: what do I check?](/help/troubleshooting/translations-not-showing) --- # How do I manage my translation glossary? Source: https://help.better-i18n.com/hi/help/ai-and-automation/managing-glossary A glossary fixes how specific terms get translated — brand names, product nouns, technical vocabulary — so "workspace" does not come back as *espace de travail* in one key and *espace* in the next. ## What a term holds | Field | Notes | |---|---| | **Term** | The source-language word or phrase | | **Type** | `brand`, `technical`, `product`, `feature` or `ui` | | **Description** | **Required** — what the term means in your product | | Part of speech | Optional; disambiguates a word that is both noun and verb | | Context | Optional; where it appears, when that changes the translation | | Per-language translation | The approved translation for each locale you want to pin | | Do not translate | Keeps the source value as-is | The description is not paperwork. It is what the model reads to decide whether the term applies to the string in front of it — a term with no explanation is a string match with no reason attached, and "run" will be pinned in a sentence about running late. ## Adding a term 1. Open the project's glossary 2. Add a term 3. Fill in the term, its type and a description 4. Pin translations for the languages you care about, or mark it **do not translate** You do not have to pin every language. A term with a description and no translations still helps: it tells the model that this word is a product noun rather than a common one. ## How it reaches a translation Approved terms are **indexed for semantic retrieval**, so a term is found even when the wording in the string is not identical to the entry. Matching is by meaning, not by exact substring — which is why an entry needs its description and type to be right, and why a near-duplicate entry is worth merging rather than leaving. Which translation paths actually carry it: - **The AI drawer (`⌘I`) and MCP agents** — the relevant terms travel with the request, so the model is told your term before it guesses one - **Machine-translation engines** — DeepL and friends keep glossaries as their own resources, so yours is **synced across** to the provider. A term added a minute before a large run should be synced first - **A one-off suggestion on a single cell does not carry the glossary.** It translates the string in front of it with your project instructions, and nothing else. So a pinned term can come back as a synonym there — if terminology matters for a value, translate it through the drawer. See [How does AI translation work?](/help/managing-translations/translate-with-ai) ## Where terms come from Each term records its source: | Source | Meaning | |---|---| | `manual` | You added it | | `website_analysis` | Extracted from a crawl of your site | | `repo_analysis` | Extracted from your repository | Terms produced by an analysis arrive as proposals — they are reviewed before they start shaping translations, which is the point of the approval step. See [How does website analysis improve translations?](/help/ai-and-automation/website-analysis). ## Editing and clearing Terms can be edited or deleted individually, and the whole glossary can be cleared in one action — useful after a rebrand, or when an early analysis filled it with terms you never really used. Clearing is a deliberate reset, not a cleanup habit: everything the model knew about your vocabulary goes with it. ## What does not exist Two things people expect and then look for: - **No CSV import or TBX export.** Terms come from the dashboard, from an analysis, or through the API — there is no file round-trip today. If your terminology lives in a spreadsheet, the API is the honest path. - **No case-sensitivity flag.** Matching is semantic rather than literal, so a case switch would not mean what it means in a find-and-replace tool. --- # How do I find the keys I need to work on? Source: https://help.better-i18n.com/hi/help/managing-translations/filtering-and-searching-keys A project with three thousand keys is unusable until you can cut it down to the forty you are actually working on. The editor's toolbar exists for that, and the useful trick is that you can save the combination once you find it. ## Search One box, top of the editor: **Search keys, source text, or visible translations…** It matches three things at once: - the full key path, including its namespace - the source-language text - the translated values **in the language columns you have visible** That last one is why searching for a Turkish phrase finds its key: the Turkish column has to be on screen for its values to be searched. Matching is plain substring, case-insensitive. `login` finds `auth.login_button`; `lgn` finds nothing. Type the piece of the key or sentence you actually remember. ## Only missing The one filter you will use daily is the **Only missing** switch: it keeps only keys that are missing at least one translation in your visible languages. Combined with a single visible language, that is your worklist for that language. **Missing first** is the softer version — nothing is hidden, the gaps just sort to the top. ## Namespaces The namespace filter is a multi-select, not a single choice: tick `auth` and `checkout` to see both. **Select all** and **Clear** are there for when the list is long. Filtering happens on the server, so narrowing by namespace makes a big project faster to load, not just tidier. ## Grouping Three ways to organise what survives the filters: | Mode | What it does | |---|---| | **Namespace** | One section per namespace | | **Group prefixes** | Splits on the shared dot-prefix — `auth.login.title` and `auth.login.subtitle` land in an `auth.login` group | | **Rules** | Your own groups, written as `Name: pattern` — e.g. `Widget: ^widget\.` | Prefix grouping is the one to reach for in a flat namespace where the real structure lives in the key names. ## Languages on screen You choose which language columns are visible, and that choice feeds both the search and the Only-missing filter — the editor only knows about what it can see. Order them by **Display order** (the order set on the Languages page) or by **Least complete**, which puts the language needing the most work on the left. ## Save the view Once a combination is right, name it and save it. It keeps the search, the namespace selection, the grouping mode, the switches, and the visible languages — so "Turkish, only missing, checkout namespace" is one click next time instead of five. Saved views are the reason not to bookmark URLs for this: they carry the parts a URL does not. ## What a shared URL does carry The editor writes some of its state into the address bar, so a link reproduces roughly where you were: ``` ?searchQuery=login&filterNamespace=auth&activeLanguage=tr ``` `selectedKeyId` is in there too, which is the useful one for pointing a colleague at a specific key rather than a general area. ## Related - [How do I use the translation editor?](/help/managing-translations/using-the-translation-editor) — editing, not finding - [How do I manage languages and track coverage?](/help/managing-translations/managing-languages) — where display order comes from --- # How do I connect GitHub and sync translations? Source: https://help.better-i18n.com/hi/help/developer-integration/github-sync-setup GitHub sync imports the translation JSON files that live in your repository into your Better i18n project. Worth being precise about what it does, because the name suggests more: it reads **files**, not code. It does not scan your source for `t()` calls — that is the CLI's job (`better-i18n scan` / `sync`), which runs on your machine or in CI. ## How GitHub sync works ``` You trigger a sync (dashboard or CLI) → Better i18n reads the translation JSON files on the configured branch + path → Language comes from the file layout → Keys and translations are imported into the project → The result is written to the CDN and a sync job is logged ``` ## Setting up GitHub sync ### Step 1: Connect your GitHub account 1. Go to **Settings → Integrations → GitHub** 2. Click **"Connect GitHub"** 3. Authorize the Better i18n GitHub App 4. Select which repositories it may read ### Step 2: Point it at your translation files 1. Pick the repository 2. Pick the branch (usually `main`) 3. Set the base path where your translation files live (e.g. `locales/` or `src/messages/`) The connect screen inspects the repository tree and pre-fills the path and format for you. Check what it detected rather than accepting it blindly — a wrong path is the usual cause of a first sync failing with "Source language files not found". ### Step 3: Confirm the locale bindings Better i18n records which file belongs to which language and shows that mapping on the connect screen and in integration settings. If a language is bound to the wrong file, fix it there before the first sync. ## Default Branch and Target Branch are not the same thing This is the single most common GitHub sync misconfiguration, so it is worth being precise about. There are two branch settings and they point in opposite directions: | Setting | Default | What it actually does | |---|---|---| | **Default Branch** | `main` | The branch we **read** your source files from, and the base every translation PR merges into | | **Target Branch** | `i18n-sync` | The branch we **create and push to**, then open the PR from | Reads always come from Default Branch. Every import path uses it: the first import, each source sync, and a full re-sync. Target Branch never affects what we read — it only names the head branch of the pull request. ### It is syncing main instead of the branch I specified You set Target Branch. Set **Default Branch** instead. Target Branch is where the PR comes *from*, so changing it does not change which branch your keys are read from. ### Keys I add on my branch never get imported Same cause: Default Branch is still `main`, so your branch is never read. Point Default Branch at the branch your locale files actually live on. ### Both are set to the same value Then we read your source from that branch and open pull requests back into it. That is a legal configuration and it does work, but it is rarely what people intend — a real customer had both set to `i18n-sync` while their source files lived on `main`, so nothing they added on `main` was ever imported. If your source lives on `main`, Default Branch belongs on `main` and Target Branch stays something separate. ### Which one do I change? Ask what you want: - **"Read my translations from branch X"** → Default Branch = X - **"Open the translation PRs against branch X"** → Default Branch = X (it is the PR base too) - **"Put the translation commits on branch Y instead of i18n-sync"** → Target Branch = Y ## How the language is detected | Layout | Example | Language from | |---|---|---| | Flat | `en.json`, `tr.json` | the filename | | Namespaced | `en/common.json`, `tr/nav.json` | the first directory | A file whose name is not a locale (`messages.json`, `strings.json`) will not bind to a language on its own — put it under a locale directory. ## Sync is triggered, not automatic There is no push webhook and no background polling: a `git push` on its own does not start a sync. You start one from the dashboard, or from the terminal: ```bash better-i18n sync ``` If you want sync on every push, run that command as a step in your CI workflow with `BETTER_I18N_API_KEY` in the environment. That is the honest way to get "automatic" — and it puts the trigger somewhere you can see it fail. ## Sync history Every run is a job you can inspect: ```bash better-i18n syncs list better-i18n syncs get better-i18n syncs cancel ``` The same history is in the dashboard, with the per-file activity log — how many files were parsed and how many keys came out of each. That log is the first place to look when a sync "worked" but a language is short of keys. A job whose worker died is closed automatically after 15 minutes, so a run stuck at "in progress" resolves itself rather than blocking the next sync forever. ## Concurrent edits Sync imports values from your files, and translations edited in the dashboard are not silently overwritten by a stale value: when a value has moved on the server since the copy you started from, Better i18n reports the conflict instead of picking a winner behind your back. Resolve it and re-run. If your repository is the single source of truth for source strings, keep the editing in the repo and treat the dashboard as review. If the dashboard is the source of truth, use `better-i18n pull` to bring translations back down rather than hand-editing files that the next sync will overwrite. ## Monorepos Each Better i18n project connects to one repository, branch and base path, so a monorepo with two apps means two projects — one per app — each pointed at its own path (`apps/web/locales`, `apps/mobile/locales`). Both can live in the same repository. --- # अनुवाद कैसे प्रकाशित करूँ ताकि मेरा ऐप उन्हें इस्तेमाल करे? Source: https://help.better-i18n.com/hi/help/getting-started/publish-translations-first-time प्रकाशन वह चरण है जो किसी अनुवाद को आपके ऐप के लिए वास्तविक बनाता है। प्रकाशित करने तक आपका लिखा सब कुछ केवल डैशबोर्ड में रहता है। बीच में न कोई बिल्ड है, न कोई डिप्लॉय। ## क्या होता है ``` Publish → अनुवाद CDN ऑरिजिन पर लिखे जाते हैं → जो बदला है, उसका कैश साफ़ किया जाता है → आपका ऐप अगली रिक्वेस्ट पर नया संस्करण ले आता है ``` ## करने का तरीका 1. अपना प्रोजेक्ट खोलें 2. **Publish** पर क्लिक करें 3. यह पढ़ें कि वह क्या सूचीबद्ध कर रहा है — असल अहम हिस्सा यही है 4. पुष्टि करें पॉपओवर ठीक-ठीक वही सूचीबद्ध करता है जो लाइव होने वाला है, और समूहबद्ध करके — ताकि आप किसी गिनती पर भरोसा करने के बजाय उसे देख सकें। ## पुष्टि से पहले सूची पढ़ें **ड्राफ़्ट भी प्रकाशित होते हैं।** यह लोगों को चौंकाता है, इसलिए सीधे कहना बेहतर है: प्रकाशन क्रिया अनुमोदित अनुवादों *और* ड्राफ़्ट, दोनों को शामिल करती है। API का अपना डिफ़ॉल्ट केवल अनुमोदित है; डैशबोर्ड इसे जानबूझकर चौड़ा करता है ताकि पॉपओवर में दिखी सूची ही वास्तव में भेजी जाए — "12 तैयार" कहकर 9 प्रकाशित करना इससे बुरा होता। नतीजा: ड्राफ़्ट का मतलब "लाइव नहीं हो सकता" नहीं है। मतलब है "अभी किसी ने इसकी ज़िम्मेदारी नहीं ली"। अगर आप चाहते हैं कि अनुमोदन एक असली दरवाज़ा बने, तो स्थिति के भरोसे रहने के बजाय प्रकाशन से पहले समीक्षा करें। देखें [अनुवादों की समीक्षा और अनुमोदन कैसे करें?](/help/review-and-approve-translations)। ## आगंतुकों को कब दिखेगा CDN अनुवाद एक मिनट के कैश के साथ परोसता है, और प्रकाशन जो बदला है उसका कैश साफ़ कर देता है — इसलिए सामान्य उत्तर सेकंड है, और **एक मिनट अधिकतम सीमा है**। जो परतें अपनी अलग देरी जोड़ सकती हैं: | परत | देरी | |---|---| | CDN edge | प्रकाशन पर साफ़; अन्यथा `max-age=60` | | SDK इन-मेमोरी कैश | उसके रिफ़्रेश अंतराल तक | | Next.js ISR | आपका सेट किया `revalidate` | अगर आपका ऐप बिल्ड समय पर अनुवाद रेंडर करता है या उन्हें एक घंटे कैश करता है, तो वह आँकड़ा आपका है, हमारा नहीं। [मैंने प्रकाशित किया पर मेरा ऐप अब भी पुराने अनुवाद दिखा रहा है](/help/publish-not-updating-app) बताता है कि कौन-सी परत पकड़े हुए है। ## CLI से ```bash better-i18n publish:status # अभी क्या लाइव होगा better-i18n publish # इसे करें ``` पाइपलाइन में पहले `publish:status` चलाना सार्थक है — "कुछ लंबित है क्या" का ईमानदार उत्तर वही है। ## रोलबैक नहीं है प्रकाशन संस्करणबद्ध नहीं होता और बहाल करने के लिए कोई स्नैपशॉट नहीं है। ग़लत प्रकाशन ठीक करने का मतलब है अनुवाद संपादित करके दोबारा प्रकाशित करना — इसमें लगभग एक मिनट लगता है, इसलिए यह शायद ही संकट बनता है; पर इसका अर्थ यह भी है कि समीक्षा बटन से *पहले* होती है, बाद में नहीं। हर प्रकाशन एक जॉब के रूप में दर्ज होता है, जिसे आप देख सकते हैं: ```bash better-i18n syncs list better-i18n syncs get ``` यह बताता है कि क्या हुआ और कब हुआ। यह उसे पलटता नहीं। ## आगे - [मैंने प्रकाशित किया पर मेरा ऐप अब भी पुराने अनुवाद दिखा रहा है](/help/publish-not-updating-app) - [CDN डिलीवरी कैसे सेट करूँ?](/help/cdn-delivery-setup) --- # How do I access content via API and MCP? Source: https://help.better-i18n.com/hi/help/content-management/content-api-and-mcp The Better i18n Content API lets you manage structured multilingual content — think blog posts, help articles, product descriptions — with a read API for your app and MCP tools for AI agents. ## What is the Content API? Unlike the Translation API (which manages i18n keys in your code), the Content API manages **free-form content entries** in a CMS-like structure. Each entry has: - A title - A rich markdown body - Custom fields you define - Multiple language translations ## Reading content Base URL: `https://content.better-i18n.com/v1/content/{org}/{project}` Authentication is the `x-api-key` header — **not** `Authorization: Bearer`: ```bash curl "https://content.better-i18n.com/v1/content/your-org/your-project/models/help-article/entries?status=published" \ -H "x-api-key: YOUR_API_KEY" ``` Three routes, all `GET`: | Route | Returns | |---|---| | `/models` | Every content model in the project | | `/models/{model}/entries` | A page of entries | | `/models/{model}/entries/{entrySlug}` | One entry | Useful query parameters on the entries route: | Parameter | Notes | |---|---| | `language` | Defaults to the project's source language | | `status` | **No default filter** — pass `status=published` or drafts come back too | | `page`, `limit` | `limit` defaults to 50, caps at 100 | | `sort`, `order` | `publishedAt`, `createdAt`, `updatedAt`, `title` · `asc` / `desc` | | `search` | Matches title and searchable text fields | | `fields`, `expand` | Trim the payload, or resolve relation fields inline | | `bodyFormat` | `markdown` (default), `html`, or `plate` | Responses are cached at the edge (`s-maxage=60`, `stale-while-revalidate=120`) and the cache is purged when you publish, so you get fresh content without a redeploy. ## Writing content **The Content API is read-only.** Creating, editing, publishing and deleting happen in the dashboard or through MCP — there is no `POST` on `content.better-i18n.com`. That split is deliberate: the read surface is the one your app calls with a key on every request, so it stays a surface that cannot mutate anything. ## Content models Before creating entries, you define a **content model** — a schema for your content type. For example: ```json { "slug": "help-article", "displayName": "Help Article", "fields": [ { "name": "excerpt", "type": "textarea", "localized": true }, { "name": "category", "type": "enum", "options": ["setup", "billing", "api"] }, { "name": "order", "type": "number" } ] } ``` ## Using the MCP server The Better i18n MCP server exposes content management as tools for AI agents: ``` # In Claude Code or Cursor: "List all help articles in my better-i18n/help project" "Translate the getting-started article to Spanish" "Create a new help article about API authentication" ``` Models: `listContentModels`, `getContentModel`, `createContentModel`, `updateContentModel` Entries: `listContentEntries`, `getContentEntry`, `createContentEntry`, `updateContentEntry`, `duplicateContentEntry`, `deleteContentEntry` Bulk: `bulkCreateEntries`, `bulkUpdateEntries` (max 200 per call), `bulkPublishEntries` (max 500) Publishing: `publishContentEntry` Two habits worth keeping: pass every language in the **initial** `createContentEntry` call rather than looping one update per language, and use `missingLanguage=fr` — not `language=fr` — when you are looking for entries that still need translating. ## Content vs. Translation API | Use case | API to use | |---|---| | `t('auth.login')` keys in your code | Translation API | | Blog posts, help articles, product descriptions | Content API | | Structured CMS content with custom fields | Content API | | Simple key-value i18n strings | Translation API | --- # What do translation statuses mean? Source: https://help.better-i18n.com/hi/help/managing-translations/translation-statuses A translation's status is deliberately small: **draft** or **approved**. Everything else you might expect — "needs review", "outdated" — is not a status here, and knowing that saves you looking for a workflow that does not exist. ## The two statuses | Status | What it means | |---|---| | **draft** | The value exists and is not vouched for. Imports, syncs and machine output land here | | **approved** | Someone stands behind it. A value you type yourself is approved, because editing it is vouching for it | A key with no value in a language has no status at all — it is simply **missing**, which is a different question from "what state is this translation in". ## Why your own edits are approved immediately Type a translation and it saves as approved. There is no self-review step, because a review queue that you feed and then approve yourself is a formality with a UI. Where review matters is other people's output — machine translation, an import, a teammate's pass — and that arrives as `draft` precisely so it can be looked at. ## Publishing is the separate axis Status is about confidence. Publishing is about reach. They are not stages of one pipeline: - Publishing takes what is eligible and pushes it to the CDN - The default gate is **approved only** - The dashboard's publish popover deliberately widens it to **draft and approved**, so what you see listed as publishable is what actually publishes That widening is worth knowing about, because it means a draft can go live from the dashboard. If you rely on draft meaning "not live", check what the publish action lists before confirming it. ## Filtering The filter has one more option than the status field does: | Filter | Finds | |---|---| | `missing` | Keys with no value in that language — what to translate next | | `draft` | Values nobody has vouched for | | `approved` | Values someone has | | `all` | Everything | `missing` is the one to reach for when you are looking for work. Filtering by a language shows keys that **have** that language; filtering by missing shows keys that need it. ## What is not modelled Being explicit, because both are reasonable expectations: - **No "outdated" flag.** Editing a source string does not mark its translations stale, and nothing goes offline on its own. Catching drift is your process, not a state the platform tracks: re-translate the keys you changed, in the same pass in which you changed them. - **No "needs review" state.** Draft is the closest thing — it means unvouched-for, and review is the act of turning it into approved. See [How do I review and approve translations?](/help/managing-translations/review-and-approve-translations). ## From the CLI ```bash better-i18n check:missing # in code, not in the project better-i18n check:unused # in the project, not found in code better-i18n publish:status # what would go live right now ``` `publish:status` is the honest answer to "what state is my project in" — it lists what is pending rather than counting states. --- # How do I set up webhooks? Source: https://help.better-i18n.com/hi/help/developer-integration/webhooks-setup Webhooks let you receive notifications when things happen in your project — translations published, keys created, content entries published, a sync finishing. ## Setting up a webhook 1. Go to **Settings → Webhooks** 2. Click **"Add webhook"** 3. Enter your endpoint URL (publicly reachable HTTPS) 4. Select the events you want 5. Add a secret if you want signed requests — you do 6. Click **Save** Then use **Send test event** to confirm your endpoint answers. Delivery attempts are logged, so a test that fails tells you the status code it got. ## Available events | Event | When it fires | |---|---| | `translations.published` | Translations published to the CDN | | `translations.updated` | Translation values changed | | `keys.created` | New translation keys added | | `keys.deleted` | Translation keys deleted | | `language.added` | A language was added to the project | | `language.removed` | A language was removed | | `sync.completed` | A GitHub or CLI sync finished | Content CMS events, if the project uses it: | Event | When it fires | |---|---| | `content.entry.created` · `content.entry.updated` | Entry written | | `content.entry.published` · `content.entry.unpublished` | Entry went live / was pulled | | `content.entry.deleted` | Entry deleted | | `content.entry.bulkUpdated` · `content.entry.bulkPublished` · `content.entry.bulkDeleted` | Bulk actions | | `content.model.created` · `content.model.updated` · `content.model.deleted` | Model lifecycle | | `content.field.added` · `content.field.updated` · `content.field.deleted` | Field lifecycle | Note the plurals — `keys.created`, not `key.created`. Subscribing to a name that does not exist fails quietly, which is a bad afternoon. ## Webhook payload format ```json { "id": "evt_9f2c...", "webhookConfigId": "wh_...", "eventType": "translations.published", "timestamp": 1774000000000, "createdAt": "2026-03-15T10:30:00.000Z", "version": "1", "data": { } } ``` `data` carries the event-specific fields. The `id` is stable across a manual replay, so dedupe on it. Headers on every request: ``` X-Better-I18n-Signature: t=,v1=,sha256= X-Better-I18n-Event: translations.published X-Better-I18n-Id: evt_9f2c... ``` ## Verifying webhook signatures The signature header carries three comma-separated parts. Verify `v1`, which binds the timestamp into the signature so a captured request cannot be replayed later: ```typescript import { createHmac, timingSafeEqual } from 'crypto' function verifyWebhook(rawBody: string, header: string, secret: string): boolean { const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string])) const t = parts.get('t') const v1 = parts.get('v1') if (!t || !v1) return false // Reject anything older than five minutes. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) } ``` Two things that break verification silently: signing the parsed body instead of the **raw** bytes, and signing `body` instead of `` `${t}.${body}` ``. The `sha256=` part is the older scheme (HMAC over the body alone) and exists only for consumers written before `v1` — ignore it in new code. ## Common use cases ### Trigger Next.js revalidation on publish ```typescript if (eventType === 'translations.published') { await fetch('https://your-app.com/api/revalidate?path=/', { method: 'POST' }) } ``` ### Notify Slack when content goes live ```typescript if (eventType === 'content.entry.published') { await slack.send({ text: `Published: ${data.entrySlug}` }) } ``` ## Delivery, failures and replay Each event is delivered **once**. There is no automatic retry and no exponential backoff, and a failing endpoint is never auto-disabled — so a deploy window where your endpoint 502s means those events are not coming back on their own. What you get instead: - Every attempt is logged with its response status and body, visible in **Settings → Webhooks** - **Redeliver** re-sends a logged event — same `id`, same bytes, fresh signature Design your handler to be idempotent (dedupe on `id`) and treat the delivery log as the source of truth for what actually arrived. If your endpoint might be briefly unavailable, prefer a queue in front of it over relying on retries that do not exist. --- # How do I navigate the project dashboard? Source: https://help.better-i18n.com/hi/help/getting-started/understanding-the-dashboard Two levels: the organization, and a project inside it. The sidebar changes depending on which one you are in, and that is the only structural thing to learn. ## Organization level | | For | |---|---| | **Overview** | Your projects | | **Members** | Invite people, change roles | | **Billing** | Plan, usage, invoices | | **Settings** | Organization name, connections | Members and billing live here, not inside a project — a person you invite belongs to the organization and works across its projects. ## Project level | | For | |---|---| | **Overview** | Coverage, recent activity, what needs attention | | **Translations** | The editor — every key, every language | | **Languages** | Which languages ship, and how far each has got | | **Content** | The Content CMS, if you have enabled it | | **Integrations** | GitHub, machine-translation providers, webhooks | | **Sync** | History of sync and publish jobs | | **AI Context** | What the AI knows about your product | | **Settings** | CDN and source language, LLM, advanced | Two of those are worth knowing exist, because their names do not give them away: **Sync** is the audit trail. Every publish and every sync is a job with a record — what changed, when, whether it finished. When someone asks "did that go out?", this is the answer. **AI Context** is where the quality of AI translation is decided. Your glossary, guidelines, and what the platform has learned from your live pages. Time spent here changes every translation afterwards; time spent editing individual values does not. ## Switching projects The project name in the sidebar switcher opens the list. It remembers where you were, so switching project keeps you on the same kind of page rather than dumping you at an overview. ## Where settings actually are The word "Settings" appears at both levels and they hold different things: - **Project → Settings** — CDN delivery and source language, LLM configuration, advanced - **Project → Languages** — adding and removing languages (not in Settings) - **Project → Integrations** — GitHub, MT providers, webhooks (not in Settings) - **Organization → Members** — people and roles - **Organization → Settings → Billing** — plan and invoices - **Account → Settings → API Keys** — your keys, at the account level If you are hunting for something, the two most-often-missed are languages and API keys. ## Shortcuts | | | |---|---| | `⌘K` / `Ctrl+K` | Command palette | | `⌘I` | Toggle the AI assistant | | `⌘/` | Every shortcut, listed | In the translation editor: | | | |---|---| | `⌘I` | Ask AI about the key you are on | | `⌘N` | Toggle a note | | `⌘↵` | Save the translation | | `Esc` | Cancel the edit | `⌘/` is the one to remember, because it lists the rest — including the content editor's formatting keys. ## Related - [How do I use the translation editor?](/help/managing-translations/using-the-translation-editor) - [How do I manage languages and track coverage?](/help/managing-translations/managing-languages) --- # How do I publish content entries? Source: https://help.better-i18n.com/hi/help/content-management/publishing-content Publishing a content entry marks it live: the entry gets a `published` status and a publish timestamp, and the Content API cache is purged so the new version is served immediately. Until then it stays a draft. ## Publishing from the dashboard 1. Open the entry in the **Content** tab 2. Review the content and its translations 3. Click **Publish** You can also choose which languages go live. Anything you leave unselected stays a draft, and a language whose body is still empty is skipped — the dashboard tells you which languages went live and which were skipped, and why. That guard is deliberate: without it an entry could show a green "Published" pill over an editor that opens blank. Models that carry no body at all (taxonomies, tags, and other metadata-only models) publish without that check. Publishing requires an **admin** role on the organization. ## Publishing via MCP ``` "Publish the getting-started help article" ``` The agent calls `publishContentEntry` with the entry id. Over MCP there is no per-language choice — every language that has content goes live. For many entries at once it calls `bulkPublishEntries`, which takes up to 500 entry ids and returns a `failed` array, so only the entries that failed need retrying. ## Where publishing happens | Surface | Can publish? | |---|---| | Dashboard | Yes — per entry, optionally per language | | MCP (`publishContentEntry`, `bulkPublishEntries`) | Yes | | Content API (`content.better-i18n.com/v1/content/…`) | No — read-only | The Content API is for reading content into your app. It has no publish endpoint; publishing is a dashboard or MCP action. ## Draft vs. published vs. archived | Status | In the dashboard | On the Content API | |---|---|---| | `draft` | Visible | Returned unless you filter | | `published` | Visible | Returned | | `archived` | Visible, marked archived | Returned unless you filter | **Read this row carefully:** the Content API does not filter by status on its own. Ask for what you want: ```bash GET https://content.better-i18n.com/v1/content/your-org/your-project/models/help-article/entries?status=published ``` Without `?status=published` a request returns drafts and archived entries alongside live ones. If your app renders whatever the API hands back, add the filter. Archiving is the reversible way to take content out of circulation — the entry and its translations stay intact. ## Bulk publishing Select multiple entries in the content list and choose a status from the bulk actions menu — up to 50 entries per action. Agents use `bulkPublishEntries` (up to 500 ids, partial success reported). ## What happens after publishing ``` Publish → Entry + its published translations flip to "published" → Webhooks fire (previous status → new status) → Content API cache purged for that org / project / model / entry / languages → Next request serves the new version ``` No rebuild or redeploy is needed — consumers fetch content at runtime. The purge is the mechanism, not the cache TTL. Responses are cached with `s-maxage=60` and `stale-while-revalidate=120`, and the purge is retried up to three times; if every attempt fails the mutation still succeeds and the TTL bounds how long a stale response can be served. So content that stubbornly refuses to update is worth a minute's wait before it counts as a problem — see [I published but my app still shows old translations](/help/troubleshooting/publish-not-updating-app). ## Unpublishing Unpublishing takes the entry back to `draft` and it stops being served as published content. You can also unpublish single languages: those translations revert to draft and the entry stays published as long as another language is still live. That is how you pull one bad translation without taking the whole entry down. --- # How do I review and approve translations? Source: https://help.better-i18n.com/hi/help/managing-translations/review-and-approve-translations Review in Better i18n is enforced by **roles and status**, not by a queue. There is no Review tab and no approval workflow to switch on — which means the setup is smaller than you might expect, and it works the moment you assign roles. ## How it actually works 1. Machine output, imports and syncs land as **`draft`** — unvouched-for 2. A value someone types is saved as **`approved`** — editing it is vouching for it 3. **Publishing requires the `approve` permission**, so the act of shipping is the act of approving That third point is the whole mechanism: a person without approve rights can translate all day and cannot put it live. ## Roles are the control | Role | Translate | Approve / publish | Integrations | |---|---|---|---| | `translator` | Yes | **No** | No | | `reviewer` | Yes | Yes | No | | `developer` | Yes | Yes | Yes | | `admin` / `owner` | Yes | Yes | Yes | So the review workflow is: invite the person doing the translating as **`translator`**, and the person accountable for the language as **`reviewer`**. The translator's work accumulates and waits; the reviewer is the only one who can release it. One detail worth knowing: **editing a source-language value also requires approve rights.** Source text is what every other language is derived from, so a translator cannot quietly change the thing everyone else is translating. ## Doing a review pass 1. Open **Translations** 2. Filter to **`draft`** — that is your queue 3. Read the source next to the translation 4. Fix what is wrong, which saves it as approved, or leave it and move on 5. Publish when the language is where you want it There is no Approve button separate from this: a value you correct is approved by that act, and a value you are happy with becomes approved when you publish it. ## Leaving a trail - **Notes** on a value record why it reads the way it does — the closest thing to a review comment, and it stays attached to the value rather than living in a thread - **History** on every value records the origin of each change (manual edit, AI, bulk update, import, note), so "who changed this and where did it come from" is answerable afterwards ## What does not exist Being explicit, since all four are reasonable things to look for: - **No "require review before publish" setting.** The permission model is the gate. If you need review enforced, do not give people approve rights. - **No reject-with-comment.** Correcting the value is the rejection; the note field is where the reasoning goes. - **No per-language reviewer assignment and no review notifications.** Who reviews which language is a decision your team holds, not a field the platform stores. - **No review metrics** — no approval-time or rejection-rate reporting. If you want a queue with age and assignment, the honest answer today is that the draft filter plus roles gets you the enforcement, and the tracking lives wherever your team already tracks work. --- # How do I create and manage API keys? Source: https://help.better-i18n.com/hi/help/developer-integration/api-keys API keys authenticate requests to Better i18n from your code, CLI, and integrations. There are two kinds, and they are created in different places for different jobs. ## The two kinds of key | Key | Created in | Prefix | What it can do | |---|---|---|---| | **Account key** | Settings → API Keys | `bi-` | Acts as you: everything your account can do (CLI, CI, admin calls) | | **Content key** | A project's Content settings | `bi_pub_` | Reads content from the Content API. Read-only, per model | The distinction that matters: an account key carries your own permissions, so it belongs on a server, in CI, or in your shell — never in a browser bundle. A content key is issued read-only by design and is the one you ship to the client. Both are sent the same way — the `x-api-key` header: ```bash curl "https://content.better-i18n.com/v1/content/acme/dashboard/models/help-article/entries?status=published" \ -H "x-api-key: bi_pub_..." ``` ## Creating an account key 1. Go to **Settings → API Keys** 2. Click **"New API key"** 3. Enter a descriptive name (e.g., "Production frontend", "GitHub Actions CI") 4. Pick an expiry — 90 days, 180 days, 365 days, or never 5. Click **Create** 6. **Copy the key immediately** — it is not shown again There is no key-type choice here: an account key is an account key. Its scope is your account's access, which is why the expiry option is worth using. ## Creating a content key Content keys are created per project, from the project's Content settings, and only by an organization **admin**. They: - carry a `bi_pub_` prefix so they are recognisable at a glance - expire after 365 days - are **read-only** — a write permission requested on a public content key is reduced to read - can be narrowed per content model, so a key can read `help-article` and nothing else Permissions are expressed per model slug, not as global scopes: ```json { "help-article": ["read"], "blog-post": ["read"] } ``` ## Key management practices - **One key per environment** — separate keys for dev, staging, and production - **Descriptive names** — include the environment and the use case: `prod-frontend`, `ci-deploy` - **Rotate on offboarding** — an account key acts as the person who made it - **Never commit keys** — use environment variables - **Ship content keys, not account keys** — if a key reaches the browser, it must be a content key ## Using a key with the CLI ```bash export BETTER_I18N_API_KEY=bi-... better-i18n sync ``` The CLI reads `BETTER_I18N_API_KEY` from the environment by default, so the key never has to appear in `better-i18n.config.ts`. ## Revoking a key 1. Go to **Settings → API Keys** 2. Find the key 3. Click **Revoke** and confirm Revoking takes effect immediately: anything still using that key starts getting 401s. Deploy the replacement first. Content keys are managed from the project's Content settings rather than this list, so a key you cannot find here is probably a content key. ## Rate limits API-key traffic is rate limited at the edge — on the order of 1000 requests per minute per account. A burst above that gets 429s; it does not revoke or degrade the key. If you are hitting it from a frontend, you are probably fetching per request where you could be caching: Content API responses are already cached at the edge for you. --- # How do I set up translation providers (DeepL, Google, Azure, Amazon)? Source: https://help.better-i18n.com/hi/help/developer-integration/translation-providers Better i18n gives you several ways to get the actual translation done — built-in AI, a machine-translation engine of your own, human translators, or files handed to an agency. ## Option 1: AI translation (built-in) The fastest path: translate keys with LLMs from the project, in bulk. Nothing to configure. **Best for:** getting to full coverage quickly, internal tools, early-stage products **Watch out for:** brand tone — review user-facing copy before it ships ## Option 2: Bring your own machine-translation engine Four engines can be connected per organization: | Provider | Engine used | |---|---| | **DeepL** | DeepL Professional | | **Google Translate** | Translation API v3 | | **Azure Translator** | Translator v3 | | **Amazon Translate** | Amazon Translate v1 | To connect one: 1. Open the project's **Integrations** 2. Pick the provider 3. Paste the credentials The credentials are validated as you save them, so a wrong key fails there rather than silently failing on your first translation run. Once connected you can: - set a **default provider**, so bulk translation uses it without asking - **disable** a provider without deleting your configuration - check **provider health** when translations start failing — that tells you whether the problem is your quota or ours - see **usage per provider**, which is what you'll want before renewing a plan ## Glossaries are pushed to the provider Engines like DeepL keep glossaries as their own resources, so your Better i18n glossary is **synced to the provider** rather than applied on top of whatever came back. That is why there is a sync step, and why a glossary term you added a minute ago should be synced before you kick off a large translation run. A consequence worth knowing: a term list that lives only in the provider's console will not be visible in Better i18n, and the sync overwrites in one direction. Keep the glossary here and let it flow out. ## Option 3: Human translators in the dashboard Invite people with the role that matches what they should do: | Role | For | |---|---| | `translator` | Writing translations — cannot approve | | `reviewer` | Approving what translators wrote | | `developer` | Keys, integrations, publishing | | `admin` | Everything, including members and billing | Invites live at the organization level, not inside a project: **Members → Invite member**. See [How do I invite team members?](/help/team-and-account/inviting-team-members). ## Option 4: Files out to an agency, files back in There is no XLIFF export screen. The paths that exist: **Out** — pull your translations as files, or read them from the CDN: ```bash better-i18n pull ``` **In** — upload the returned files in the dashboard. The importer accepts **JSON, YAML, XML/XLIFF and `.properties`**, several languages at once, up to 5 MB per file. So an agency round-trip works today, and XLIFF is fine as the interchange format — just note that the export side is a CLI pull rather than a button. ## Combining approaches Most teams end up hybrid: 1. AI translates everything first — a cheap, fast baseline 2. Humans review what matters — marketing, legal, anything a customer reads closely 3. An MT engine covers long-tail languages where a human pass is not worth it | Scenario | Reasonable approach | |---|---| | 1–3 languages | AI + light review | | 4–10 languages | AI + MT for coverage, humans on the core surfaces | | 10+ languages | AI primary, MT fallback, agency for your top markets | Roles are what make this shape safe: the machine sets the baseline, and only someone with approve rights can ship it. See [How do I review and approve translations?](/help/managing-translations/review-and-approve-translations). --- # v2.1.0 — GitHub sync that finishes, immutable CDN versions, and a hardened dashboard Source: https://help.better-i18n.com/hi/changelog ### New - **Versioned CDN delivery.** Every publish now writes immutable `/v/{version}/` files next to the legacy paths, and `@better-i18n/core` 0.14.2 reads them when the manifest carries a version. Deploys can no longer serve a half-updated translation file. - **Target languages detected from your repo.** Connecting a GitHub repository with `ja-JP.json` and `zh-TW.json` now adds `ja` and `zh-hant` automatically and imports the existing translations, so the first publish is a real diff instead of an empty file. - **Organization activity and account deletion.** Every organization now has an activity log, and you can delete your account from Settings; a 14-day grace period keeps the data recoverable. ### Improved - **GitHub connect always comes back.** Installing or adding a GitHub account returns you to the project you started from, in a popup or in the same tab. Reconnect after a revoked token is one click, and the dashboard shows exactly which repository is linked. - **"Sync now" respects the initial import.** Manual sync on a freshly connected repo runs the same import as setup, instead of a partial source sync. - **Install snippets show current versions.** Setup screens read package versions from npm, so `@better-i18n/sdk 3.5.0` is what you copy. - **Docs links inside the dashboard** point to the new help center and developer docs. - **Onboarding follow-up.** If a project sits in setup for a day, we send one email with the exact step that is missing. ### Security - Dashboard responses carry `X-Frame-Options: DENY` and `X-Content-Type-Options: nosniff`. - Repository connect and GitHub actions check the organization role through the same permission layer as the rest of the API. - Soft-deleted keys are denormalized onto translations, so a deleted key can never reach the CDN through a stale join. ### Fixed - Deleting an organization while a member was being added could leave orphaned rows; the delete is now transactional. - Language-only publishes no longer regenerate translation files for GitHub projects. --- # v1.2.0 — CLI health tools & namespaced delivery Source: https://help.better-i18n.com/hi/changelog ### New - **`better-i18n doctor`** — a full i18n health report (score 0–100) and **`better-i18n scan`** to find hardcoded strings not wrapped in `t()`. - **Namespaced CDN delivery.** Large projects can serve one file per namespace per locale, so each page fetches only the namespaces it needs. ### Improved - Every CLI command now supports `--json` for machine-readable output in CI and agent workflows. --- # v1.4.0 — MCP servers & Agent Skill Source: https://help.better-i18n.com/hi/changelog ### New - **MCP servers.** `@better-i18n/mcp` (11 translation tools) and `@better-i18n/mcp-content` (17 content tools) let ChatGPT, Claude, Cursor, and Gemini manage your i18n directly. Remote server at `https://mcp.better-i18n.com/mcp`. - **Agent Skill.** `npx skills add better-i18n/skills` gives your AI agent permanent knowledge of SDK patterns, CDN behavior, and key conventions — no per-session prompting. ### Improved - AI translation is now context-aware, using namespace descriptions and your glossary. --- # v1.6.0 — Native mobile SDKs Source: https://help.better-i18n.com/hi/changelog ### New - **iOS (Swift) SDK** — reactive translations in SwiftUI via `I18nStore`, with offline-first two-phase loading and WidgetKit support. - **Flutter SDK** (`better_i18n`) — pure Dart, reactive `BetterI18nProvider`, 4-tier offline fallback, no codegen. - **Expo SDK** (`@better-i18n/expo`) — i18next integration that works in Expo Go with MMKV/AsyncStorage offline caching. All three share the same CDN infrastructure as the web SDKs — manage translations once, ship everywhere. --- # v1.8.0 — OAuth 2.0 for third-party apps Source: https://help.better-i18n.com/hi/changelog ### New - **OAuth 2.0 Authorization Code** support. Build platforms, IDE plugins, and agents that manage translations on behalf of your users — scoped, consent-driven, and revocable. - **Installation tokens** (`bi_oat_`): short-lived (1h), per-organization, per-project scoped credentials that work across REST, MCP, and CDN. ### Security - Two-layer token model: access tokens mint installation tokens but can't read resources; installation tokens read/write but can't escalate. --- # v2.0.0 — Content CMS & Admin SDK Source: https://help.better-i18n.com/hi/changelog ### New - **Content CMS.** Manage structured, localized content — blog posts, FAQs, marketing pages — with content models, fields, and entries, delivered through the same edge CDN as your translations. Includes built-in view analytics. - **Admin SDK** (`@better-i18n/admin`). A typed, server-side SDK for programmatic access to projects, keys, translations, content, analytics, and sync from your own backend, scripts, or CI. ### Improved - Analytics now break down views by entry, language, country, and time series.