The git-based CMS for multilingual MDX sites
English content lives in MDX files in your repo. AI translations live in a SQLite file next to them. Zod validates both, and a typed runtime reads everything at build time. No CMS server, no network at request time.
pnpm add scribe-cms zod better-sqlite3How it works
- 1
Define a schema
Content types are Zod schemas. Mark which fields get translated, which stay English-only, and how documents reference each other.
// scribe.config.ts defineContentType({ id: "blog", path: "/blog/{slug}", slugStrategy: "localized", schema: z.object({ title: field.translatable(z.string().min(1)), description: field.translatable(z.string().min(50)), author: field.relation("author"), heroImage: field.structural(z.string().optional()), }), }); - 2
Write content
One MDX file per document, committed to your repo. The file name is the English slug; frontmatter is validated against the schema.
--- title: "Hello, world" description: "A first post that says hello to the world." author: jane publishedAt: "2026-01-15" --- The body is MDX. **Markdown** and <Components /> both work. - 3
Translate what changed
Translatable content is hashed, so only missing or stale pages are sent to Gemini, through the Batch API at half the token price. The prompt is tuned for transcreation, so copy reads like it was written in the target language. Output is parsed as MDX before it is stored; a bad response fails the run, not your site.
export GEMINI_API_KEY=... npx scribe translate --locale fr de --dry-run # show the worklist npx scribe translate --locale fr de # translate, validate, store git add .scribe/store.sqlite # translations live in git - 4
Read it, typed
The runtime infers document types from your config. Lists, lookups, relations, hreflang alternates, and sitemap entries all come back fully typed.
const scribe = createScribe(config); const posts = scribe.blog.list("fr"); // BlogDoc[] const { document } = scribe.blog.resolve(slug, "fr"); // EN fallback built in const author = scribe.blog.related(document!, "author"); // AuthorDoc const hreflang = scribe.blog.alternates(document!);
What you get
Incremental translation
Translations are keyed to a hash of their English source. Edit one paragraph and only that document is retranslated; a dry run shows the cost first.
Reads like a native wrote it
The prompt is built for transcreation: a native copywriter's voice that recreates idioms instead of translating word for word.
Validated before stored
Every translation is parsed as MDX and re-checked against your Zod schema before it is stored. Model mistakes fail the command, not production.
Typed end to end
Zod schemas, inferred document types, no codegen. Relations come back dereferenced and fully typed.
Inline tokens
Links, assets, and untranslatable strings embedded right in prose. Edit a token and every locale updates instantly, no retranslation.
SEO built in
hreflang alternates, sitemaps with x-default, canonicals, noindex, JSON redirect rules, and locale fallback chains.
Everything in git
MDX files plus one committed SQLite store. Snapshots record what each translation was made from; scribe history shows the timeline.
No server
Everything is read from disk at build time; works with Next.js, Astro, or any Node stack. A local studio adds browsing, search, and coverage at a glance.
AI-first by design
Agents read and edit MDX with the same tools they use on code, and every change is a diff you review. No API between the model and the words.
Built in the open
scribe-cms is MIT-licensed and developed on GitHub. This site runs on it. Every page here is scribe content, with a committed SQLite store behind ten locales. Read the source, open an issue, or send a pull request.