Skip to content
better-i18n.com
इस पेज पर

Better i18n's headless CMS organizes content into models and entries. Models define the structure (like a database table), and entries are the individual pieces of content (like rows). You define each model once in the dashboard, then query entries from your app with the SDK's chainable query builder.

Anatomy of a model #

PropertyDescription
slugUniversal identifier across all locales (e.g. blog, product-page)
nameHuman-readable label shown in the dashboard
kindcollection (many entries) or singleton (one entry, like a homepage)
includeBodyWhether entries have a long-form rich-text body
fields[]Custom fields (text, number, boolean, date, image, reference, JSON)

Field types #

TypeUse forLocalized?
textShort strings — titles, namesYes
textareaMulti-line plain textYes
richtextLong-form content with formattingYes
numberCounts, prices, ratingsNo (usually)
booleanToggle flags — featured, archivedNo
dateDate only — publish date, event startNo
datetimeDate + timeNo
enumA fixed set of optionsNo
mediaImage / file referenceNo
relationLink to another entry (see Relations)No

There is no array or group field type, and relation points to a single entry. To model "more than one of something", see below — do not reach for numbered fields.

Modeling repeating content #

When you need more than one of something, pick the right home for it instead of inventing extra fields:

  1. Page-composition / repeating structured content → body blocks. Hero sections, feature grids, FAQ lists, CTAs, and any repeating section belong in the entry body, not as sidebar fields.

    Read this before you plan around blocks. There is no defineBlock() in any SDK package — an earlier version of this page said there was, and it was wrong. What ships today is a block catalogue you populate over MCP: registerBlock upserts one block by (project, slug), bulkRegisterBlocks does several, listBlocks reads them back, deleteBlock removes one. A block is a slug, a display name, an optional category and description, and a JSON Schema for its params — and that schema does support arrays and nested objects, which is what makes it the right home for repetition.

    The catalogue is what the CMS and AI agents read when composing an entry. Rendering stays yours: you keep a component per block slug in your own app and match on the slug. If you need editor previews, previewUrl takes a static image you host and previewOrigin a base URL the CMS can iframe.

  2. References to other entries → a relation field. A post pointing to its category, author, or tags is a relation; resolve it with .expand() (see Relations).

  3. Never model repetition as numbered sidebar fields. Feature 1, Feature 2, Tag 1 Slug… is an anti-pattern — it produces an unusable wall of fields and cannot scale. Use a body block (case 1) or a relation (case 2).

Localized vs. universal fields #

A field with localized: true stores a separate value per language. Updating English doesn't affect Turkish.

A field with localized: false (the default for non-text fields) is shared — one value across all locales.

Listing models #

TypeScript
const models = await client.getModels();

Response:

JSON
[
  {
    "slug": "blog-posts",
    "displayName": "Blog Posts",
    "kind": "collection",
    "entryCount": 24
  },
  {
    "slug": "homepage",
    "displayName": "Homepage",
    "kind": "singleton",
    "entryCount": 1
  }
]

Query builder #

client.from(modelSlug) starts a chainable query builder. Every method returns a new immutable builder — calls never mutate the original — so you can safely branch and reuse base queries.

The builder is thenable: await it directly to execute the query. You do not need to call a separate .execute() or .get() method.

TypeScript
const { data, error, total, hasMore } = await client
  .from("blog-posts")
  .eq("status", "published")
  .order("publishedAt", { ascending: false })
  .limit(10);

List result shape:

FieldTypeDescription
dataContentEntryListItem[] | nullArray of entries, or null on error
errorError | nullError object if the request failed, otherwise null
totalnumberTotal matching entries across all pages
hasMorebooleanWhether more pages exist beyond the current page

Filtering #

By status #

TypeScript
const { data: published } = await client
  .from("blog-posts")
  .eq("status", "published");

Valid statuses: published, draft, archived.

By custom field #

TypeScript
const { data: engineering } = await client
  .from("blog-posts")
  .eq("status", "published")
  .filter("category", "engineering");
TypeScript
const { data: results } = await client
  .from("blog-posts")
  .search("kubernetes");

Sorting #

Use .order(field, options?). Valid fields: publishedAt, createdAt, updatedAt, title.

TypeScript
await client.from("blog-posts").order("publishedAt", { ascending: false });
await client.from("blog-posts").order("title", { ascending: true });

Pagination #

.limit(n) sets entries per page; .page(n) selects the page (1-based).

TypeScript
let page = 1;
let hasMore = true;

while (hasMore) {
  const result = await client
    .from("blog-posts")
    .eq("status", "published")
    .limit(20)
    .page(page);

  hasMore = result.hasMore;
  page++;
}

Field selection #

Use .select(...fields) to request only specific fields. slug and publishedAt are always included.

TypeScript
const { data } = await client
  .from("blog-posts")
  .select("title", "category")
  .eq("status", "published");

Single entry #

.single(slug) fetches one entry by slug. Also thenable.

TypeScript
const { data: post, error } = await client
  .from("blog-posts")
  .single("hello-world");

Response shape (excerpt):

JSON
{
  "slug": "hello-world",
  "status": "published",
  "title": "Hello World",
  "body": "## Welcome\n\n...",
  "bodyHtml": "<h2>Welcome</h2><p>...</p>",
  "availableLanguages": ["en", "tr", "de"],
  "translationStatus": { "en": "published", "tr": "draft" }
}

The body field is always Markdown. Use bodyHtml when you need pre-rendered HTML.

Slug vs localized slug #

TypeScript
// Universal slug — same for all languages
entry.slug // "getting-started"

// Localized slug from a custom field (when configured)
entry.translations.en.customFields.localized_slug // "getting-started"
entry.translations.tr.customFields.localized_slug // "baslangic"

Custom fields #

Custom field values are spread directly onto the entry object — there is no nested customFields wrapper.

TypeScript
const { data: post } = await client
  .from("blog-posts")
  .single("hello-world");

console.log(post.readingTime); // "5 min"
console.log(post.category);    // "Engineering"

Relations #

Use .expand(...fields) to resolve relation references inline. Without .expand(), relation fields are omitted from the response.

TypeScript
const { data: posts } = await client
  .from("blog-posts")
  .eq("status", "published")
  .expand("author", "category");

When .expand() is used, a relations key appears on each entry:

JSON
{
  "slug": "hello-world",
  "title": "Hello World",
  "relations": {
    "author": { "slug": "alice-johnson", "title": "Alice Johnson" },
    "category": { "slug": "engineering", "title": "Engineering" }
  }
}

Language #

Use .language(code) to request localized content. Falls back to the source language automatically when a translation doesn't exist.

TypeScript
const { data: post } = await client
  .from("blog-posts")
  .language("fr")
  .single("hello-world");

Check availableLanguages and translationStatus on single-entry responses to verify a translation exists before redirecting:

TypeScript
const hasFrench = post.availableLanguages.includes("fr");
const isFrPublished = post.translationStatus?.["fr"] === "published";

Error handling #

Every query returns { data, error }. Check error before using data:

TypeScript
const { data: post, error } = await client
  .from("blog-posts")
  .single("hello-world");

if (error) {
  console.error("Failed to fetch:", error.message);
  return null;
}

console.log(post.title);

Legacy methods #

getEntries() and getEntry() still work but are deprecated. Use from() for all new code — see API Reference.