Models & Entries
Query content 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). The SDK exposes a Supabase-style chainable query builder that makes it easy to filter, sort, paginate, and expand content in one readable chain.
Content Models
A content model defines the shape of your content. Each model has a slug, display name, kind, and optional custom fields.
const models = await client.getModels();Response:
[
{
"slug": "blog-posts",
"displayName": "Blog Posts",
"description": "Company blog articles",
"kind": "collection",
"entryCount": 24
},
{
"slug": "changelog",
"displayName": "Changelog",
"description": "Product updates and releases",
"kind": "collection",
"entryCount": 8
}
]Model kinds:
collection— Multiple entries (blog posts, changelog, FAQ)single— One entry (about page, homepage content)
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 |
Listing Entries
Chain methods to build your query, then await to fetch.
// All published entries, newest first
const { data: posts, total } = await client
.from("blog-posts")
.eq("status", "published")
.order("publishedAt", { ascending: false });Filtering by Status
Use .eq("status", value) to filter by entry status:
// Only published entries
const { data: published } = await client
.from("blog-posts")
.eq("status", "published");
// Only drafts
const { data: drafts } = await client
.from("blog-posts")
.eq("status", "draft");
// Archived entries
const { data: archived } = await client
.from("blog-posts")
.eq("status", "archived");Filtering by Custom Field
Use .filter(field, value) to filter by any custom field defined on the model:
// Entries where "category" custom field equals "engineering"
const { data: engineering } = await client
.from("blog-posts")
.eq("status", "published")
.filter("category", "engineering");Full-Text Search
Use .search(term) to search entry titles:
const { data: results } = await client
.from("blog-posts")
.search("kubernetes");Sorting
Use .order(field, options?) to control sort order. The field must be one of "publishedAt", "createdAt", "updatedAt", or "title".
// Newest published first (default)
await client.from("blog-posts").order("publishedAt", { ascending: false });
// Oldest created first
await client.from("blog-posts").order("createdAt", { ascending: true });
// Alphabetical by title
await client.from("blog-posts").order("title", { ascending: true });Pagination
Use .limit(n) to set entries per page and .page(n) to select the page (1-based).
// First page, 10 entries
const { data, total, hasMore } = await client
.from("blog-posts")
.eq("status", "published")
.limit(10)
.page(1);Pagination loop:
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await client
.from("blog-posts")
.eq("status", "published")
.limit(20)
.page(page);
console.log(`Page ${page}: ${result.data?.length} entries (${result.total} total)`);
hasMore = result.hasMore;
page++;
}Field Selection
Use .select(...fields) to request only specific fields. When omitted, all fields are returned. slug and publishedAt are always included.
// Only slug, title, and publishedAt (faster, less data)
const { data } = await client
.from("blog-posts")
.select("title")
.eq("status", "published");
// Include body for excerpt previews and custom fields by name
const { data } = await client
.from("blog-posts")
.select("title", "body", "category");Single Entry
Use .single(slug) to fetch a full entry by slug. This returns a SingleQueryBuilder which is also thenable — await it directly.
const { data: post, error } = await client
.from("blog-posts")
.single("hello-world");Single result shape:
| Field | Type | Description |
|---|---|---|
data | ContentEntry | null | The entry, or null if not found or on error |
error | Error | null | Error object if the request failed, otherwise null |
Response:
{
"id": "entry-uuid",
"slug": "hello-world",
"status": "published",
"publishedAt": "2026-01-15T10:00:00Z",
"sourceLanguage": "en",
"availableLanguages": ["en", "tr", "de"],
"availableLanguageDetails": [
{ "code": "en", "name": "English", "countryCode": "US" },
{ "code": "tr", "name": "Turkish", "countryCode": "TR" },
{ "code": "de", "name": "German", "countryCode": "DE" }
],
"translationStatus": {
"en": "published",
"tr": "published",
"de": "draft"
},
"title": "Hello World",
"body": "## Welcome\n\nWelcome to our blog...",
"bodyMarkdown": "## Welcome\n\nWelcome to our blog...",
"bodyHtml": "<h2>Welcome</h2><p>Welcome to our blog...</p>",
"category": "Engineering",
"readingTime": "5 min"
}The body field is always a Markdown string. Use bodyHtml when you need pre-rendered HTML — for example, when embedding content in a non-Markdown environment. bodyMarkdown is an explicit alias for body available alongside bodyHtml for clarity.
Checking translation readiness:
const { data: post } = await client
.from("blog-posts")
.single("hello-world");
// Check which languages are fully published
const publishedLanguages = Object.entries(post.translationStatus ?? {})
.filter(([, status]) => status === "published")
.map(([code]) => code);
// Render a language picker with display names
post.availableLanguageDetails?.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
));Custom Fields
Content models can define custom fields beyond the base fields (title, slug, body). 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");
// Access custom fields directly on the entry
console.log(post.readingTime); // "5 min"
console.log(post.category); // "Engineering"List items also return custom fields flat:
{
"slug": "hello-world",
"title": "Hello World",
"publishedAt": "2026-01-15T10:00:00Z",
"category": "Engineering",
"readingTime": "5 min"
}For type-safe access to custom fields, see the TypeScript guide.
Relations
Content entries can reference entries from other models using relation fields. Use .expand(...fields) to resolve these references inline — without a second fetch.
When you do not call .expand(), relation fields appear as null or are omitted from the response. .expand() tells the API to resolve the referenced entry and embed it into the relations key.
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 with the resolved data. The relation object's own custom fields are also flat — no nested customFields wrapper:
{
"slug": "hello-world",
"title": "Hello World",
"publishedAt": "2026-01-15T10:00:00Z",
"category": "Engineering",
"readingTime": "5 min",
"relations": {
"author": {
"id": "user-uuid",
"slug": "alice-johnson",
"title": "Alice Johnson",
"modelSlug": "users",
"avatar": "https://cdn.example.com/avatars/alice.jpg",
"role": "Engineering"
},
"category": {
"id": "cat-uuid",
"slug": "engineering",
"title": "Engineering",
"modelSlug": "categories"
}
}
}Access relation fields directly:
const post = posts[0];
post.relations?.author?.title; // "Alice Johnson"
post.relations?.author?.avatar; // "https://cdn.example.com/avatars/alice.jpg"
post.relations?.category?.title; // "Engineering".expand() works the same way on single entry queries:
const { data: post } = await client
.from("blog-posts")
.expand("author")
.single("hello-world");
post.relations?.author?.title; // "Alice Johnson"If a relation field has no value, its key will be null in relations.
expand only fetches fields that are actually set — unreferenced fields are omitted.
Language
Use .language(code) to request localized content in a specific language. This filters all localized fields — title, body, and any localized custom fields — to the requested language.
const { data: post } = await client
.from("blog-posts")
.language("fr")
.single("hello-world");You can combine .language() with any other query builder method:
// List published entries in Turkish
const { data: posts } = await client
.from("blog-posts")
.language("tr")
.eq("status", "published")
.order("publishedAt", { ascending: false })
.limit(10);Language fallback behavior:
When you request a language that does not have a translation, the SDK falls back to the source language automatically:
// If "ja" translation doesn't exist, returns "en" (source) content
const { data: post } = await client
.from("blog-posts")
.language("ja")
.single("hello-world");
// post.title → English title (fallback)Checking available languages before fetching:
Single-entry responses include availableLanguages (code list) and availableLanguageDetails (rich objects with display names). Use these to build language pickers or to verify a translation exists before redirecting:
// First fetch the entry in the source language
const { data: post } = await client
.from("blog-posts")
.single("hello-world");
// Check if a specific language is available
const hasFrench = post.availableLanguages.includes("fr");
// Build a language selector from rich descriptors
const languageOptions = post.availableLanguageDetails?.map((lang) => ({
value: lang.code,
label: lang.name,
flag: lang.countryCode,
}));
// Only fetch localized version if translation is published
const isPublished = post.translationStatus?.["fr"] === "published";
if (isPublished) {
const { data: frPost } = await client
.from("blog-posts")
.language("fr")
.single("hello-world");
}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 post:", error.message);
return null;
}
// data is safe to use here
console.log(post.title);For list queries:
const { data: posts, error, total } = await client
.from("blog-posts")
.eq("status", "published");
if (error) {
console.error("Failed to fetch posts:", error.message);
return [];
}
console.log(`Fetched ${posts.length} of ${total} posts`);Legacy API
The getEntries() and getEntry() methods still work but are deprecated. Use from() for all new code. For a complete list of deprecated signatures, see the API reference.