Reference
CLI
The verbaly command lives in @verbaly/compiler. Add it to your dev dependencies so npx verbaly resolves: package managers link a command only for a package you installed yourself, and the plugins depend on the compiler without installing it for you.
Commands
14 commands, in the order the cycle uses them. Each one links to its section.
Scaffolding
verbaly init sets a project up in one command. You get a typed config (.ts if you have a tsconfig, .mjs otherwise) and your catalogs: the source one, plus one for every locale you pass in --locales. Then it tells you what to wire next. Astro, Nuxt, Next.js, SvelteKit and Vite each have their own integration; any other bundler uses @verbaly/unplugin.
npx verbaly init --locales es,pt- created: verbaly.config.ts, locales/en.json, locales/es.json, locales/pt.json
- detected: vite
- next steps: 1.
pnpm add verbaly @verbaly/vite - 2. add verbaly() to the plugins in vite.config
Re-running is safe: an existing config or catalog is reported as “kept” and left byte-identical.
Health check
verbaly doctor is init's sibling for projects already running: it inspects the whole setup and reports every finding with the exact command that fixes it. Green means healthy; problems exit 1.
npx verbaly doctor- Passed config: verbaly.config.ts found
- Passed catalogs: 3 locales (en, es, pt) in locales/
- Passed plugin: @verbaly/vite installed for vite
- Warning types: src/verbaly.d.ts is stale fix: run
npx verbaly typegen - Warning orphans: 2 catalog keys are no longer referenced (old.title, hero.cta) fix: run
npx verbaly extract --pruneto drop them - Warning outdated: 1 translation written for an older source text (es: hero.title) fix: update them, or keep them with
npx verbaly review --approve - Error imports: 1 file imports t from a verbaly package, which never exports it (src/app.ts) fix: t comes from your instance (React:
const t = useT()) or fromvirtual:verbaly - Error translations: 1 broken translation (es): present but not rendering what the source renders fix: run
npx verbaly checkto read what each one lost
One command to answer “why isn’t this translating?” It looks at four things.
- Your setup: the config, the catalogs, whether every language code is one a browser can read, whether the URL mode matches the URLs
renderwrites, whether the right integration for your framework is installed, and whether the generated types are up to date where your framework reads them,tsconfig.jsonincluded in Next.js. - Your translations: the missing ones, the broken ones, the outdated ones, the warnings, keys only a translation has, keys written with two texts, and keys no line of your code asks for any more.
- What your build cannot see:
timported from a verbaly package, which no bundler can resolve, atunder another name, whose text is never extracted, a file outsideincludethat writest`…`, which no build translates, and a message that kept a plural or format block as plain text. - Any file it could not read, as a warning. A source file's text is not extracted, but the file may still build fine in your project. A
.verbaly-state.jsonbroken by a merge is restored from git: deleting it would mark every draft as reviewed.
A clean report means check passes too. doctor fails on everything check fails on, plus setup faults like that import, and never on something that builds and renders fine.
Wrapping hardcoded text
verbaly wrap finds hardcoded text in your JSX and wraps it in t`…` for you. It reports first, and --write applies. Anything ambiguous is listed for a human instead of guessed, and a file where t is not available is left untouched and reported, so the project always still compiles.
Porting from another library
verbaly migrate ports the catalogs you already have. Your file keeps its shape, nested or flat, and the only thing it must rewrite is the interpolation: {{name}} becomes {name}, because a doubled brace is how Verbaly writes a literal one. It reports first, and --write applies.
--plurals is the optional half: it merges _one and _other into one message with variants, which is what buys automatic plural selection, and inside it the count prints itself already localized. It knows the older key + key_plural spelling too. Without the flag those keys keep working exactly as they are.
What it will not guess is listed instead: an i18next format inside the braces, a {{- unescaped}} value, a $t() nesting, and a merge that would overwrite a key you already use. Those messages are left exactly as they were.
Extraction
verbaly extract scans your sources (.js/.ts/.jsx/.tsx, plus .svelte, .vue and .astro), writes new messages to the source catalog, adds "" placeholders to the rest and regenerates the types. It names any file it could not parse instead of stopping on it. Every source text that followed your code gets one line with the text it had before, so an edit you made in the code reads as a change and one made in the catalog shows what it undid. Then it warns, with file and line, about what would otherwise go unnoticed: a key written with two texts, a t under another name, a message that kept a plural or format block as literal text, and a value a translator would see as {_0}. With --watch, each warning is said once, and again only if it comes back.
Type generation
verbaly typegen writes the TypeScript types of your messages and nothing else, from the text your build ships, where your framework reads them: src/verbaly.d.ts in a Vite or SvelteKit app, .verbaly/types.d.ts in Next.js, and Astro's and Nuxt's own types folder. extract, the dev servers and the build write the same file, so you only need it where none of them ran: a fresh clone, or a CI job about to run tsc.
Coverage
verbaly status shows translation coverage per locale at a glance (es: 45/48 translated), how many machine translations still wait for review, how many are broken and how many were written for an older source text. It is informational only and never fails; --json gives the same numbers to badges and tooling.
Pseudo-localization
verbaly pseudo fills a QA catalog (en-XA by default) from the text your build ships in the source locale: accented letters, ⟦…⟧ markers and ~33% padding. Text that stays clean in the pseudo build is hardcoded; layouts that clip reveal themselves before a real translation does.
Params, variant blocks and named tags survive verbatim, guaranteed by the same structural validation as translate. Re-running regenerates the whole catalog.
Machine translation
verbaly translate closes the loop: write → extract → translate → check green. The default provider uses Claude (@anthropic-ai/sdk as an optional peer + ANTHROPIC_API_KEY); batches of 20 per request, each one telling the model which source files the text lives in, so it translates with context. Any current Claude model works through translate.model or --model, and a batch the model declines goes to a fallback model instead of coming back untranslated.
Every translation is checked: placeholders and tags must survive verbatim. If one doesn't, the translation is rejected and the entry stays "", so check keeps flagging it until it's fixed.
No lock-in: plug your own provider in verbaly.config.ts. In TypeScript, TranslateProvider types it for you, and origins tells you which source files each text appears in, so you can translate it for where it is used:
import type { TranslateProvider } from '@verbaly/compiler';
const provider: TranslateProvider = async ({ sourceLocale, targetLocale, messages, origins }) => ({
/* key → translation */
});
export default { translate: { provider } };Review what is waiting
Machine output is a draft, not reviewed work. translate records everything it writes in locales/.verbaly-state.json (committed with your catalogs, never edited by hand), and verbaly review closes the loop: read the drafts, then approve them. The same file remembers which source text each translation was written for, from the moment it is written, so when you change a text, its translations wait in review too, as outdated: update them, or approve them if they still hold. Each one is listed with the text that ships next to its translation, so you can judge it right there, and --approve followed by keys accepts only those.
Want merges blocked until a human signs off? Add --drafts to the check in your CI: it keeps failing while unreviewed machine translations remain, and --outdated does the same for translations written for an older source text. A translator file brought in with import counts as review on its own.
Translators & TMS
Humans in the loop? verbaly export writes translator-ready XLIFF 2.0, CSV or gettext PO files per locale and verbaly import brings them back, structure-validated with the same gate as translate. The full flow, TMS options included, lives in Work with translators.
verbaly import validates every entry and rejects and reports the broken ones. PO entries marked fuzzy count as untranslated, and imported keys stop counting as unreviewed drafts, unless you pass --draft for a file nobody has read yet. A file's language is read as one of yours, so gettext's pt_BR fills your pt-BR. A file for a new language starts its catalog under its proper code, unless your config lists its locales: then a language outside the list is skipped with a warning and exit code 1, because your app would never load it.
Export also writes native mobile resources: --format android-xml for Android's res/ layout and --format ios-strings for Xcode. Untranslated keys are left out so the app falls back to your source language (--missing applies only to the translator formats). Every format writes to verbaly-export/ unless you point --out elsewhere.
What the gate checks
check asks three questions. Is every message translated? Does every key the code writes exist? And can each translation render what the source renders? A filled-in translation is not automatically a working one, so these fail check too, wherever they came from: a translator, a machine or a hand edit. Every build lists the same problems and finishes, so run check in CI.
| The translation | Why it fails |
|---|---|
lost a {param}, or renamed it | the value never reaches the text |
lost an <em>, or gained one | the emphasis or the link marker is gone |
| turned a plural block into plain text | one wording for every count |
has a plural block with no other case | every count it does not list renders empty |
Some findings are warnings instead: they print and the exit code stays 0, because the text still renders.
- A plural set missing forms the language needs: Polish and Arabic ask for more than English.
- A case like
=0that the source had and the translation dropped, so that count falls back toother. - A key only a translation has. Where your code reads it, the source language shows the key itself; where nothing does, it only adds weight, which
extract --pruneremoves. - One key written with two texts: every place shows the one written first, by file path, and the report lists every place that disagrees.
- A text in your source catalog that differs from the one in your code: the code's text is the one that ships.
- A translation written for an older source text, read against the text in your code. Add
--outdatedtocheckto make it fail, and it is listed with the failures.
A build runs the same gate, and when it finds warnings it prints one line that says how many, then passes.
Whatever fails, the report ends by telling you how to fix it: scaffold and fill a message that has no translation yet, correct the key of one that lives in no catalog, repair the message itself when it is broken.
CI example
- run: pnpm install
- run: npx verbaly check --reporter github # each finding becomes a PR annotation
- run: pnpm buildWith --reporter github, every finding shows up as an annotation on the pull request, on the file and line where the text lives. Errors arrive as errors and warnings as warnings, so a warning never turns the job red. Without it you get the plain list, with the same exit code.
Static rendering (SSG)
verbaly render is what kills the flash of untranslated text on a static site. It walks your built HTML and fills in every data-verbaly element, once per language, using the real runtime: plurals, Intl formatting, data-verbaly-args, attributes and whitelisted rich text. Inside a language, links to pages that language also has keep its prefix, so whoever arrives stays there. Getting them there is --redirect, below.
- Each language gets
<html lang>and<html dir>set (right-to-left locales mirror correctly); the source locale is filled in place, the rest mirror todist/<locale>/…. - The
data-verbalyattributes stay in the output, so client-side locale switching keeps working on top of the pre-rendered page. - Messages are HTML-escaped (never injected), missing keys are reported and left untouched, re-runs are idempotent, and translated attributes pass the same guards as the runtime: unsafe URLs blocked,
style/srcdocnever written. - Named links: set
render.linksinverbaly.config.*(ordata-verbaly-linksper element) and rich messages pre-render real<a href>elements, attribute-escaped, withjavascript:blocked. - Multi-locale SEO: set
render.baseUrl(or pass--base-url) and every page gets reciprocalhreflangalternates;--sitemapwrites an i18n sitemap,--cleandrops stale locale pages. - The sitemap lists only pages a search engine should index: one that redirects, or carries
noindex, is left out. Every page is still translated and published, so a visitor who lands on one gets it in their language. Leave more out by name withrender.exclude, a list of globs against the path inside your build directory. - Using a different data attribute?
--attributepoints the renderer at it, the same way the runtime takes one, so both sides stay in step. - Self-canonical mirrors:
rel="canonical"andog:urlare rewritten to each locale's own URL, because a cross-locale canonical would make search engines ignore the hreflang set. - Send a visitor to their language.
--redirect(orrender.redirect) puts a small script at the top of your home page that moves them before anything is drawn. It only fires there: a search engine asking for a deep page has to get that page, not a detour. A saved choice always wins, the query and the hash come along, and it can never loop, because it does nothing once you are already in that language. Add{ on: 'all' }if you want every page of your source language to route too, andstorageKeyto point it at the key you save the choice under. - Site served from a subfolder?
--base /app(orrender.base) tells the renderer where the site starts, so links inside each language read/app/es/docsinstead of pointing nowhere. The same option exists onlocaleFromPathandlocalePathso the runtime agrees with the build.
Flags
| Flag | Default | Description |
|---|---|---|
| --root | cwd | Project root |
| --dir | locales | Catalogs directory |
| --source | en | Source locale |
| --locales | from files | Run on these locales only, the source always included (init: the ones to create) |
| --prune | off | Drop keys no longer referenced (extract only) |
| --watch | off | Re-extracts as source files change: live extraction for webpack, Rspack and Rollup setups (extract only) |
| --locale | en-XA | Pseudo-locale id (pseudo only) |
| --site | dist | Built site directory (render only) |
| --base | site root | Subpath the site is served under (render only) |
| --redirect | off | Send a visitor on the home page to their language (render only) |
Flags are validated per command: a flag that belongs to another command exits with an actionable error instead of being silently ignored: translate --locale es tells you it means --locales.
Config file
Flags win over verbaly.config.{js,mjs,ts,mts,json} at the project root, and the plugin takes the same options. Every one of them, with its default, lives in Configuration.
Coding agents
Your coding agent can run this same cycle without touching the shell, through the @verbaly/mcp server. That channel has a page of its own. Coding agents covers the server, its tools and resources, the installable Agent Skill and the llms.txt index this site serves.