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
| Property | Description |
|---|---|
slug | Universal identifier across all locales (e.g. blog, product-page) |
name | Human-readable label shown in the dashboard |
kind | collection (many entries) or singleton (one entry, like a homepage) |
includeBody | Whether entries have a long-form rich-text body |
fields[] | Custom fields (text, number, boolean, date, image, reference, JSON) |
Field types
| Type | Use for | Localized? |
|---|---|---|
text | Short strings — titles, names | Yes |
richtext | Long-form content with formatting | Yes |
number | Counts, prices, ratings | No (usually) |
boolean | Toggle flags — featured, archived | No |
date | Publish date, event start | No |
image | Media reference | No |
reference | Link to another entry | No |
json | Free-form structured data | Optional |
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:
| Field | Type | Description |
|---|---|---|
data | ContentEntryListItem[] | null | Array of entries, or null on error |
error | Error | null | Error object if the request failed, otherwise null |
total | number | Total matching entries across all pages |
hasMore | boolean | Whether 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");Full-text search
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.