Table of Contents
If you've ever used Supabase, you know the magic of supabase gen types typescript. One command, and your entire database schema becomes a TypeScript interface. No more guessing column names. No more as any casts. Your editor knows your data shape before you write a single query.
We wanted that same experience for content. So we built it.
The problem: Content without types is content without confidence
Every headless CMS gives you an API. You define a "Blog Post" model with fields like author, category, featured, and read_time. You fetch entries. You get JSON back. And then... you're on your own.
// The "trust me bro" pattern
const posts = await client.getEntries("blog-posts");
posts[0].read_tme // typo — no error, just undefined at runtime
posts[0].author // string? object? null? who knows
This is the state of most headless CMS SDKs today. Contentful, Sanity, Strapi — they all return untyped JSON or require you to manually write interfaces that drift from your actual schema within weeks.
Supabase solved this for databases. Prisma solved it for ORMs. We solved it for localized content.
What we built
npx better-i18n content:types
That's it. One command. It connects to your project, reads all your content models, and generates a TypeScript file:
// Auto-generated by @better-i18n/cli — do not edit manually.
import type { ContentEntry, ContentEntryListItem, RelationValue } from "@better-i18n/sdk";
/** Blog Posts — Localized blog posts for the landing site */
export interface BlogPostsFields extends Record<string, string | null> {
author: RelationValue; // required relation → no null
category: RelationValue; // required relation → no null
featured: string | null; // optional boolean
read_time: string; // required number
}
export type BlogPosts = ContentEntry<BlogPostsFields>;
export type BlogPostsListItem = ContentEntryListItem<BlogPostsFields>;
/** Pricing Plans — Plan definitions with multi-currency pricing */
export interface PricingPlansFields extends Record<string, string | null> {
plan_id: "free" | "pro" | "enterprise"; // enum → literal union!
name: string;
description: string;
monthly_prices: string | null;
// ...
}
Now your queries are fully typed:
const { data } = await client
.from<BlogPostsFields>("blog-posts")
.eq("featured", "true")
.execute();
// data[0].author — typed as RelationValue
// data[0].read_tme — TypeScript error! Caught at compile time.
Learning from Supabase: What we borrowed and what we changed
Supabase's gen types command set the gold standard for schema-to-types tooling. Here's what we took from their approach and where we diverged.
What we borrowed
1. Zero-config philosophy. Supabase reads your project config and "just works." We do the same — the CLI reads i18n.config.ts for your project identifier and .env for your API key. No flags required for the happy path.
2. Single command, single output file. supabase gen types --lang=typescript outputs one file. We do the same: content:types generates src/content-types.ts. One source of truth.
3. CI/CD integration as a first-class pattern. Supabase documents running gen types in GitHub Actions to catch schema drift. We provide the same pattern:
- name: Generate content types
run: npx better-i18n content:types
env:
BETTER_I18N_API_KEY: ${{ secrets.BETTER_I18N_API_KEY }}
- name: Check for uncommitted changes
run: git diff --exit-code src/content-types.ts
Where we diverged
1. Enum fields become literal unions. Supabase maps Postgres enums to string unions. We do the same for CMS enum fields — but we go further. A plan_id field with values free, pro, enterprise becomes "free" | "pro" | "enterprise", not just string. This gives you exhaustive switch checking in TypeScript.
2. Relation fields get their own type. In Supabase, a foreign key is just a UUID string. In our system, a relation field is typed as RelationValue — an object with id, slug, title, and modelSlug. This means your editor knows that post.author.slug exists without you looking up the API docs.
3. Required vs. optional is schema-driven. If a field is marked as required in the CMS, the generated type omits | null. If it's optional, null is included. This directly reflects your content model's validation rules — no guessing.
4. Two type aliases per model. For each model, we generate both ContentEntry (full entry with all metadata) and ContentEntryListItem (lighter shape for list queries). Supabase has Row, Insert, and Update variants; our two variants match how the SDK actually returns data.
The SDK changes that made this possible
Type generation is only useful if the SDK accepts those types. When we started, our getEntries() method had no generic parameter:
// Before: untyped list results
const { items } = await client.getEntries("blog-posts");
// items[0] is ContentEntryListItem — Record<string, string | null>
// No custom field types. You're flying blind.
We had to fix the SDK's type foundation first:
// After: generic flows through the entire chain
const { items } = await client.getEntries<BlogPostsFields>("blog-posts");
// items[0].author — RelationValue ✓
// items[0].category — RelationValue ✓
// items[0].nonexistent — TypeScript error ✓
The same generic was threaded through the chainable query builder:
// Fully typed chainable API
const { data } = await client
.from<BlogPostsFields>("blog-posts")
.eq("featured", "true")
.limit(10)
.execute();
This required changes across 5 files in the SDK — types.ts, http.ts, client.ts, query-builder.ts, and index.ts. The key insight was that ContentEntryListItem needed to accept the same <CF> generic parameter as ContentEntry, and every method in the chain had to thread it through without dropping it.
.env auto-loading: A small detail that matters
One thing that frustrated us about some CLIs: you set up credentials in .env, but the CLI doesn't read them. You end up passing --api-key every time or wrapping commands in dotenv-cli.
We looked at how Supabase handles this. Their CLI uses godotenv (Go's dotenv library) to auto-load .env files before parsing config.toml. The load order is:
.env.{environment}.local.env.local.env.{environment}.env
We simplified this for our use case:
.env.local(git-ignored, user-specific).env(committed defaults)
Shell variables always win — we never override what's already set. The loader is zero-dependency (no dotenv package — just 40 lines of file reading), and it runs before any command executes.
# Just works — no flags needed
echo 'BETTER_I18N_API_KEY=bi_pub_xxx' >> .env.local
npx better-i18n content:types
The field type mapping
Here's the complete mapping from CMS field types to TypeScript:
| CMS Field | TypeScript (required) | TypeScript (optional) |
|---|---|---|
text, textarea, richtext | string | string | null |
number | string | string | null |
boolean | string | string | null |
date, datetime | string | string | null |
enum (e.g., active/draft) | "active" | "draft" | "active" | "draft" | null |
media | string | string | null |
relation | RelationValue | RelationValue | null |
You might notice that number and boolean map to string, not number or boolean. This is intentional — the Content API returns all custom field values as strings (or null). The generated types reflect what the API actually returns, not what the field conceptually represents. Honest types are better than convenient lies.
What's next
This is v1 of content type generation. Here's what we're planning:
--watchmode — Regenerate types when content models change, similar toprisma generate --watch- Typed client factory — A
createTypedClient()that bakes yourContentTypeMapinto the client, soclient.from("blog-posts")is automatically typed without passing the generic - Onboarding wizard — When you enable Content CMS in the dashboard, a step-by-step flow that generates your API key, installs the SDK, and runs
content:typesin one go
Try it
npm install -g @better-i18n/cli
better-i18n content:types --project your-org/your-project --api-key your_key
Or if you already have i18n.config.ts and .env:
npx better-i18n content:types
The generated types work with @better-i18n/sdk v3.2+ and are fully compatible with the chainable query builder API.
Type safety shouldn't stop at your database. Your content deserves the same treatment.