solid-translate
AI-powered i18n for SolidJS — full feature parity with General Translation, but open-source and BYOK (bring your own API key).
Write your app in one language. Wrap text in <T>. Get translations generated automatically at build time. No JSON key management. No external service required.
Features
<T>Component — wrap any text for translation. Source text = key (no JSON wrangling)<Var>— protect dynamic content from translation<Num>— locale-aware number formatting viaIntl.NumberFormat<Currency>— locale-aware currency formatting<DateTime>— locale-aware date/time formatting<Plural>— CLDR plural rules (zero/one/two/few/many/other)<LocaleSelector>— drop-in locale picker component- AI Context —
contextprop for disambiguation ("Save" = save file vs. save money) - Auto Locale Detection — detects from
navigator.languageswhenlocaleprop is omitted msg()— mark strings for extraction outside of JSX- CLI Tool — translate JSON, Markdown, and MDX files from the command line
checkCommand — CI freshness gate, no AI calls (exit 1 when translations are stale)- Vite Plugin — build-time translation with smart change detection
- Lazy Locale Chunks —
virtual:solid-translate/lazycode-splits each locale, loads on demand without suspending - Locale Persistence — opt-in
persistLocaleremembers the user's choice inlocalStorage - GitHub Action —
omniaura/solid-translate@v1for CI/CD translation automation - BYOK — use any Vercel AI SDK provider (OpenRouter, OpenAI, Anthropic, Google, etc.)
Install
bun add solid-translate
bun add -d ai @ai-sdk/openai # or any AI SDK provider
Quick Start
1. Configure the Vite plugin
// vite.config.ts
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { solidTranslate } from "solid-translate/vite";
import { createOpenAI } from "@ai-sdk/openai";
const openrouter = createOpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
export default defineConfig({
plugins: [
solidPlugin(),
solidTranslate({
sourceLocale: "en",
targetLocales: ["es", "fr", "de", "ja"],
localesDir: "./src/locales",
model: openrouter("openai/gpt-4o-mini"),
autoExtract: true, // auto-discover <T> and msg() strings
}),
],
});
2. Write your app
import { TranslationProvider, T, Var, Num, Plural, useTranslation, LocaleSelector } from "solid-translate";
import translations from "virtual:solid-translate";
function App() {
return (
<TranslationProvider
sourceLocale="en"
translations={translations}
// locale="es" ← optional! auto-detects from browser if omitted
>
<Page />
</TranslationProvider>
);
}
function Page() {
const { t } = useTranslation();
const [count, setCount] = createSignal(3);
const userName = () => "Alice";
return (
<div>
<h1><T>Welcome to our app!</T></h1>
{/* Dynamic content protected with <Var> */}
<p><T>Hello <Var>{userName()}</Var>, nice to see you!</T></p>
{/* AI context for disambiguation */}
<button><T context="save a document to disk">Save</T></button>
{/* Explicit key */}
<a><T id="nav.home">Home</T></a>
{/* Interpolation */}
<p>{t("items.count", { count: count() })}</p>
{/* Pluralization */}
<Plural n={count()}
zero="No items in your cart"
one="1 item in your cart"
other={`${count()} items in your cart`}
/>
{/* Locale-aware number */}
<p>Total: <Num>{1234567.89}</Num></p>
{/* Locale switcher */}
<LocaleSelector labels={{ en: "English", es: "Español", fr: "Français" }} />
</div>
);
}
3. Build
bun run build
On the first build, the plugin generates translation files:
src/locales/
├── en.json # Source (auto-generated or manual)
├── es.json # AI-generated
├── fr.json # AI-generated
├── de.json # AI-generated
├── ja.json # AI-generated
└── .solid-translate.lock # Change tracking
On subsequent builds, only changed/new keys are re-translated. Check everything into git.
API Reference
Components
<T> — Translatable Text
// Source text as key (no JSON file entry needed)
<T>Hello world</T>
// Explicit key
<T id="greeting">Hello world</T>
// With interpolation
<T params={{ name: userName() }}>Hello {{name}}</T>
// AI context for disambiguation
<T context="financial institution, not river bank">Bank</T>
// Mixed JSX with Var
<T>Welcome <Var>{userName()}</Var>, you have <Num>{count()}</Num> items</T>
<Var> — Variable Protection
Marks dynamic content that should NOT be translated. When used inside <T>, the surrounding text is translated but <Var> content is preserved.
<T>Hello <Var>{userName()}</Var></T>
// Spanish: "Hola {userName()}"
<Num> — Number Formatting
Locale-aware number formatting using Intl.NumberFormat.
<Num>{1000000}</Num> // "1,000,000" (en) / "1.000.000" (de)
<Num options={{ style: "percent" }}>{0.42}</Num> // "42%"
<Num options={{ notation: "compact" }}>{1500}</Num> // "1.5K"
<Currency> — Currency Formatting
<Currency currency="USD">{29.99}</Currency> // "$29.99" (en-US) / "29,99 $US" (fr)
<Currency currency="EUR">{1000}</Currency> // "€1,000.00" (en) / "1.000,00 €" (de)
<DateTime> — Date/Time Formatting
<DateTime>{new Date()}</DateTime>
<DateTime options={{ dateStyle: "long" }}>{new Date()}</DateTime>
<DateTime options={{ hour: "numeric", minute: "numeric" }}>{Date.now()}</DateTime>
<Plural> — Pluralization (CLDR)
Uses Intl.PluralRules for locale-correct plural forms.
<Plural n={count()}
zero="No items"
one="1 item"
two="2 items" // Used in Arabic, Welsh, etc.
few={`${count()} items`} // Used in Polish, Czech, etc.
many={`${count()} items`} // Used in Arabic, etc.
other={`${count()} items`}
/>
<LocaleSelector> — Locale Picker
Drop-in <select> for switching locales.
// Auto-generates display names via Intl.DisplayNames
<LocaleSelector />
// Custom labels
<LocaleSelector labels={{ en: "English", es: "Español" }} />
// Subset of locales
<LocaleSelector locales={["en", "es"]} />
Hooks
useTranslation()
Full translation context.
const { t, locale, setLocale, sourceLocale, availableLocales } = useTranslation();
t("greeting") // translated string
t("items.count", { count: 3 }) // with interpolation
locale() // "es"
setLocale("fr") // switch locale
availableLocales() // ["en", "es", "fr", ...]
useLocale()
Lightweight hook for just locale info.
const { locale, setLocale, sourceLocale, availableLocales } = useLocale();
<TranslationProvider>
Root provider. Wraps your app.
<TranslationProvider
translations={translations} // Translation dictionaries or lazy manifest
sourceLocale="en" // Source locale (default: "en")
persistLocale // Optional: persist locale to localStorage
// persistLocale={{ key: "my-app:locale" }} // ...with a custom storage key
// locale="es" // Optional: explicit locale
// // If omitted, auto-detects from navigator.languages
>
{children}
</TranslationProvider>
translations accepts either the eager record from virtual:solid-translate
or the lazy manifest from virtual:solid-translate/lazy (see
Lazy per-locale loading).
With persistLocale enabled, the initial locale is read from localStorage
(when it's still a valid locale) before falling back to browser detection,
and setLocale writes the choice back. Storage access is guarded, so SSR
and storage-disabled environments degrade gracefully. An explicit locale
prop always wins.
Lazy per-locale loading
By default virtual:solid-translate inlines every locale dictionary into
your main bundle. For large apps (thousands of strings × many locales) use
virtual:solid-translate/lazy instead — each locale becomes its own chunk,
fetched on demand via dynamic import:
import { TranslationProvider } from "solid-translate";
import translations from "virtual:solid-translate/lazy";
<TranslationProvider translations={translations} persistLocale>
<App />
</TranslationProvider>;
The lazy manifest has this shape:
{
sourceLocale: string;
locales: string[]; // source + target locales
loaders: Record<string, () => Promise<Record<string, string>>>;
}
Loading is fully non-blocking: it never throws and never triggers
<Suspense>. While a locale's dictionary is in flight (or if its chunk
fails to load), t() and <T> render the source-language text; the UI
swaps to the translated text reactively once the loader resolves. Loaded
dictionaries are cached for the session, and availableLocales() derives
from locales in the manifest.
You can also import a single locale's dictionary directly:
const es = await import("virtual:solid-translate/locale/es");
es.default; // Record<string, string>
msg() — Shared Strings
Mark strings for extraction outside of JSX. At build time, the Vite plugin extracts them. At runtime, use t() to translate.
import { msg } from "solid-translate";
// Mark for extraction (build-time)
const SAVE = msg("Save changes");
const DELETE = msg("Delete");
// Translate at runtime
function Toolbar() {
const { t } = useTranslation();
return (
<div>
<button>{t(SAVE)}</button>
<button>{t(DELETE)}</button>
</div>
);
}
Vite Plugin Config
solidTranslate({
sourceLocale: "en", // Source locale (default: "en")
targetLocales: ["es", "fr"], // Target locales
localesDir: "./src/locales", // Locale files dir (default: "./src/locales")
model: openai("gpt-4o-mini"), // Any Vercel AI SDK model
systemPrompt: "...", // Custom AI prompt (optional)
batchSize: 50, // Keys per API call (default: 50)
autoExtract: true, // Auto-extract <T> and msg() strings (default: false)
include: ["src/**/*.tsx"], // Files to scan for extraction
})
CLI
For translating locale files, JSON, Markdown, and MDX outside of the Vite build.
# Initialize config
npx solid-translate init
# Extract strings from source files
npx solid-translate extract
# Translate everything
npx solid-translate translate
# Verify translations are fresh (CI primitive — no AI calls, no writes)
npx solid-translate check
npx solid-translate check --json # machine-readable report
check re-extracts source strings and compares keys, content hashes, and
context hints against .solid-translate.lock, then verifies every target
locale file contains every key. Exit code 0 means everything is fresh; exit
code 1 means stale, with a report of missing/changed/orphaned keys per
locale. It needs no API key and never modifies files, so it's safe to run
on every pull request.
CLI Config (solid-translate.config.json)
{
"sourceLocale": "en",
"targetLocales": ["es", "fr", "de"],
"localesDir": "./src/locales",
"provider": "openrouter",
"model": "openai/gpt-4o-mini",
"batchSize": 50,
"include": ["src/**/*.tsx", "src/**/*.ts"],
"files": {
"json": {
"include": ["i18n/[locale]/*.json"]
},
"md": {
"include": ["docs/[locale]/**/*.md"]
},
"mdx": {
"include": ["content/[locale]/**/*.mdx"]
}
}
}
The [locale] placeholder is replaced with each target locale. Source files are found by replacing [locale] with the source locale.
Environment Variables
OPENROUTER_API_KEY=... # OpenRouter
OPENAI_API_KEY=... # OpenAI
ANTHROPIC_API_KEY=... # Anthropic
GOOGLE_API_KEY=... # Google AI
Using with different AI providers
The plugin and CLI accept any Vercel AI SDK compatible model:
// OpenRouter (access to 100+ models)
import { createOpenAI } from "@ai-sdk/openai";
const openrouter = createOpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
const model = openrouter("anthropic/claude-sonnet-4-5");
// OpenAI directly
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o-mini");
// Anthropic directly
import { anthropic } from "@ai-sdk/anthropic";
const model = anthropic("claude-haiku-4-5-20251001");
// Google
import { google } from "@ai-sdk/google";
const model = google("gemini-2.0-flash");
CI/CD Integration
GitHub Action
Use the official action to keep translations up to date automatically:
# .github/workflows/translate.yml
name: Translate
on:
push:
branches: [main]
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: omniaura/solid-translate@v1
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
with:
commit: true
commit-message: "chore: update translations"
Action Inputs
| Input | Default | Description |
|---|---|---|
command |
both |
extract, translate, check, or both |
working-directory |
. |
Working directory |
commit |
false |
Auto-commit updated translation files |
commit-message |
chore: update translations |
Commit message |
fail-on-stale |
true |
With command: check — fail the step when translations are stale |
node-version |
22 |
Node.js version |
package-manager |
npm |
npm, bun, pnpm, or yarn |
Action Outputs
| Output | Description |
|---|---|
changed |
true if translation files were updated |
files |
Newline-separated list of changed translation files |
stale |
With command: check — true if translations are out of date |
The action reports and commits changes for JSON, Markdown, MDX, and .solid-translate.lock files only. Package manifests and package manager lockfiles are intentionally excluded.
Examples
PR gate — fail CI when translations are stale (no API key needed):
# .github/workflows/i18n-check.yml
name: i18n check
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: omniaura/solid-translate@v1
with:
command: check
package-manager: bun
check runs extraction only — no AI provider, no writes — so it's a fast,
deterministic gate. Set fail-on-stale: "false" to keep the step green and
branch on the stale output instead:
- uses: omniaura/solid-translate@v1
id: i18n
with:
command: check
fail-on-stale: "false"
- name: Comment on stale translations
if: steps.i18n.outputs.stale == 'true'
run: echo "Translations are stale — run 'solid-translate translate'"
Translate on main and open a PR with the updates:
# .github/workflows/translate.yml
name: Translate
on:
push:
branches: [main]
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: omniaura/solid-translate@v1
id: translate
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Open PR with translation updates
if: steps.translate.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
branch: chore/update-translations
title: "chore: update translations"
commit-message: "chore: update translations"
body: |
Automated translation update.
Changed files:
${{ steps.translate.outputs.files }}
Translate on push and commit back:
Use auto-commit only on trusted refs where GITHUB_TOKEN can push to the checked-out branch, such as push events on your own repository. For pull requests, leave commit disabled and use the changed/files outputs to decide whether to fail CI or open a separate update PR.
name: Translate
on:
push:
branches: [main]
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: omniaura/solid-translate@v1
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
with:
commit: true
package-manager: bun
Extract only (no AI calls):
- uses: omniaura/solid-translate@v1
with:
command: extract
Use output in subsequent steps:
- uses: omniaura/solid-translate@v1
id: translate
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Create PR with translations
if: steps.translate.outputs.changed == 'true'
env:
TRANSLATION_FILES: ${{ steps.translate.outputs.files }}
run: |
printf 'Updated files:\n%s\n' "$TRANSLATION_FILES"
Manual CI Setup
Add to your build script for automatic translations on every deploy:
{
"scripts": {
"translate": "solid-translate translate",
"build": "bun run translate && vite build"
}
}
Or directly in a workflow:
- name: Translate
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: npx solid-translate translate
- name: Build
run: bun run build
How change detection works
The .solid-translate.lock file tracks a content hash for each source key. On build:
- Source locale file is read and each value is hashed
- Hashes are compared against the lock file
- Only new or changed keys are sent to the AI for translation
- If a key's
contextprop changed, it's re-translated for better accuracy - Unchanged translations are preserved from existing locale files
- Deleted source keys are removed from all target files
This means you can safely check in all translation files. Rebuilds are free unless you change source text.
TypeScript
Types for all the virtual modules (virtual:solid-translate,
virtual:solid-translate/lazy, and virtual:solid-translate/locale/*)
ship with the package. Reference them once in your env.d.ts or
vite-env.d.ts:
/// <reference types="solid-translate/virtual" />
or add them to tsconfig.json:
{
"compilerOptions": {
"types": ["solid-translate/virtual"]
}
}
No hand-written declare module blocks needed.
Comparison with General Translation (gt-react)
| Feature | gt-react | solid-translate |
|---|---|---|
<T> component |
||
<Var> variable protection |
||
<Num> number formatting |
||
<Currency> formatting |
||
<DateTime> formatting |
||
<Plural> CLDR rules |
||
<LocaleSelector> |
||
| AI context disambiguation | ||
| Auto locale detection | ||
Shared strings (msg()) |
||
| CLI for JSON/MD/MDX | ||
| CI/CD integration | ||
| Official GitHub Action | ||
| Zero refactoring | ||
| BYOK (bring your own key) | (SaaS) | |
| No vendor lock-in | ||
| SolidJS native | (React) | |
| Build-time translation | (runtime) | |
| Open source | Partial | MIT |
License
MIT