Better I18NBetter I18N

Models & Entries

Define content models in the dashboard and query them with the chainable from() API

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. Blocks are code-first: you register them per project with a defineBlock()-style declaration backed by a JSON Schema — which does support arrays and nested objects — and editors insert them via the / slash picker.
  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.

The top-level entry slug is always universal. If you need per-language URL slugs (e.g. /en/about vs /tr/hakkimizda), add a custom field with localized: true (typically localized_slug).

Listing models

const models = await client.getModels();

Response:

[
  {
    "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.

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

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

Valid statuses: published, draft, archived.

By custom field

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

Sorting

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

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).

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.

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

Single entry

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

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

Response shape (excerpt):

{
  "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

// 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.

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

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

For type-safe access to custom fields, see TypeScript.

Relations

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

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:

{
  "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.

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:

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

Error handling

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

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.

On this page