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
richtextLong-form content with formattingYes
numberCounts, prices, ratingsNo (usually)
booleanToggle flags — featured, archivedNo
datePublish date, event startNo
imageMedia referenceNo
referenceLink to another entryNo
jsonFree-form structured dataOptional

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