Models & Entries
Bu sayfada
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 |
textarea | Multi-line plain text | Yes |
richtext | Long-form content with formatting | Yes |
number | Counts, prices, ratings | No (usually) |
boolean | Toggle flags — featured, archived | No |
date | Date only — publish date, event start | No |
datetime | Date + time | No |
enum | A fixed set of options | No |
media | Image / file reference | No |
relation | Link 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:
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:registerBlockupserts one block by(project, slug),bulkRegisterBlocksdoes several,listBlocksreads them back,deleteBlockremoves 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,
previewUrltakes a static image you host andpreviewOrigina base URL the CMS can iframe.References to other entries → a
relationfield. A post pointing to its category, author, or tags is a relation; resolve it with.expand()(see Relations).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 #
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 |
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"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.
Better I18N