Query SDK
@contentrain/query is an optional TypeScript convenience layer for consuming Contentrain content. It generates a fully typed client from your model definitions — think Prisma, but for content.
SDK is optional
Contentrain stores content as plain JSON and Markdown. Any language that reads JSON (Go, Python, Swift, Kotlin, Rust) can consume your content directly. The SDK adds type safety, query API, and relation resolution for TypeScript projects.
Why a Generated Client?
You could read .contentrain/content/ files directly — and for non-TypeScript platforms, that's exactly what you should do. But for TypeScript projects, raw file reads give you:
- No TypeScript types
- No query API (filtering, sorting, pagination)
- No relation resolution
- No locale-aware data loading
- Manual JSON parsing for every model
The generated client solves all of this with zero runtime dependencies and exact types from your model schemas.
Prisma Pattern
contentrain generate reads your models and writes a typed client to .contentrain/client/. You import it via #contentrain — Node.js native subpath imports, no plugin magic.
Install
pnpm add @contentrain/queryRequirements:
- Node.js 22+
- A Contentrain project with
.contentrain/config.json
Quick Start
# Generate the client
npx contentrain generateThis writes:
.contentrain/client/
index.mjs — ESM entry (query runtime + re-exports)
index.cjs — CJS entry (NestJS, Express, legacy tooling)
index.d.ts — Generated TypeScript types from model schemas
data/
{model}.{locale}.mjs — Static data modules per model/locale
{model}--{slug}.{locale}.mjs — Document entries (one module per slug)
{model}.mjs — Non-i18n models (no locale suffix)It also updates your package.json with subpath imports:
{
"imports": {
"#contentrain": {
"types": "./.contentrain/client/index.d.ts",
"import": "./.contentrain/client/index.mjs",
"require": "./.contentrain/client/index.cjs",
"default": "./.contentrain/client/index.mjs"
},
"#contentrain/*": {
"types": "./.contentrain/client/*.d.ts",
"import": "./.contentrain/client/*.mjs",
"require": "./.contentrain/client/*.cjs",
"default": "./.contentrain/client/*.mjs"
}
}
}Then import and query:
import { query, singleton, dictionary, document } from '#contentrain'
const posts = query('blog-post').locale('en').where('status', 'published').all()
const hero = singleton('hero').locale('en').get()
const labels = dictionary('ui-labels').locale('en').get()
const article = document('blog-article').locale('en').bySlug('welcome')Studio Bridge
@contentrain/query is the local typed read surface for TypeScript apps. Studio extends the same content contract into remote delivery workflows:
- local apps can import
#contentrainfor generated, type-safe reads - Studio adds API keys, CDN publishing, media distribution, and team-facing delivery controls
- both sides should point back to the same model definitions and locale structure
Use Ecosystem Map for the package relationship, and use Studio docs when the same content needs remote delivery:
API Reference
The generated client exposes four entry points, one for each model kind.
QueryBuilder — Collections
For collection models (multiple entries, object-map storage).
import { query } from '#contentrain'
// Full API chain
const posts = query('blog-post')
.locale('en') // Set locale
.where('status', 'published') // Exact match filter
.sort('date', 'desc') // Sort by field, optional order
.limit(10) // Limit results
.offset(5) // Skip results (pagination)
.include('author', 'tags') // Resolve relation fields (1 level deep)
.all() // Returns T[]
// Get first match
const latest = query('blog-post')
.locale('en')
.sort('date', 'desc')
.first() // Returns T | undefined| Method | Signature | Returns | Description |
|---|---|---|---|
locale | locale(lang: string) | this | Set the content locale |
where | where(field, value) | this | Equality filter (shorthand for eq) |
where | where(field, op, value) | this | Operator filter: eq, ne, gt, gte, lt, lte, in, contains |
sort | sort(field, order?) | this | Sort by field, order is 'asc' or 'desc' |
limit | limit(n: number) | this | Limit number of results |
offset | offset(n: number) | this | Skip first N results |
include | include(...fields) | this | Resolve relation fields |
count | count() | number | Return count of matching entries |
all | all() | T[] | Execute query, return all matches |
first | first() | T | undefined | Execute query, return first match |
Where operator examples:
query('plans').where('slug', 'ne', 'free').all()
query('plans').where('price', 'gte', 10).where('price', 'lte', 50).all()
query('starters').where('framework', 'in', ['nuxt', 'next']).all()
query('blog').where('title', 'contains', 'Guide').count()SingletonAccessor — Singletons
For singleton models (single entry, e.g. site settings, hero section).
import { singleton } from '#contentrain'
const hero = singleton('hero')
.locale('en')
.include('featured_post') // Resolve relations on singletons too
.get() // Returns T| Method | Signature | Returns | Description |
|---|---|---|---|
locale | locale(lang: string) | this | Set the content locale |
include | include(...fields) | this | Resolve relation fields |
get | get() | T | Get the singleton entry |
DictionaryAccessor — Dictionaries
For dictionary models (flat key-value string maps, ideal for i18n).
import { dictionary } from '#contentrain'
// Get all key-value pairs
const allLabels = dictionary('ui-labels')
.locale('en')
.get() // Returns Record<string, string>
// Get a single value by key
const label = dictionary('ui-labels')
.locale('en')
.get('submit_button') // Returns string | undefined
// Parameterized templates
const message = dictionary('ui-labels')
.locale('en')
.get('add-entry', { model: 'blog' })
// Value: "Add a new entry to {model}"
// Returns: "Add a new entry to blog"| Method | Signature | Returns | Description |
|---|---|---|---|
locale | locale(lang: string) | this | Set the content locale |
get | get() | Record<string, string> | Get all key-value pairs |
get | get(key) | string | undefined | Get a single value by key |
get | get(key, params) | string | Get value with {placeholder} replacement |
Parameterized Templates
Dictionary values can contain {placeholder} syntax. The get(key, params) overload replaces matched placeholders with provided values. Unmatched placeholders are left as-is.
DocumentQuery — Documents
For document models (markdown files with frontmatter).
import { document } from '#contentrain'
// Find by slug
const article = document('blog-article')
.locale('en')
.include('author') // Resolve relations in frontmatter
.bySlug('getting-started') // Returns T | undefined
// Query with filters
const techDocs = document('doc-page')
.locale('en')
.where('category', 'tech')
.all() // Returns T[]
// Get first match
const latest = document('blog-article')
.locale('en')
.first() // Returns T | undefined| Method | Signature | Returns | Description |
|---|---|---|---|
locale | locale(lang: string) | this | Set the content locale |
where | where(field, value) | this | Equality filter (shorthand) |
where | where(field, op, value) | this | Operator filter (same operators as QueryBuilder) |
sort | sort(field, order?) | this | Sort by frontmatter field (asc default) |
include | include(...fields) | this | Resolve relation fields |
bySlug | bySlug(slug) | T | undefined | Find document by slug |
count | count() | number | Return count of matching documents |
all | all() | T[] | Execute query, return all matches |
first | first() | T | undefined | Execute query, return first match |
media / mediaBody — Delivery URLs
Two more exports, media(value, baseOverride?) and mediaBody(markdown, baseOverride?), are generated only when a media delivery base is configured at generate time — set cdn.url in config.json or run contentrain generate --mediaBaseUrl <base> (--cdnBaseUrl still works as a deprecated alias). Without a configured base, neither export exists on the client at all — not a pass-through, an absent export — so import { media } from '#contentrain' fails to resolve. media() resolves a stored media/... path; mediaBody() resolves every ](media/...) link and image embed inside a markdown string:
import { media, mediaBody } from '#contentrain'
media('media/original/hero.webp') // → '{mediaBaseUrl}/media/original/hero.webp'
media('https://images.unsplash.com/x.jpg') // → unchanged (external pass-through)
mediaBody('See  for more.')
// → 'See  for more.'Both are idempotent — external URLs (http(s)://, //, data:) and already-absolute delivery URLs pass through untouched — and are the local-mode counterpart of CDN mode's MediaAccessor.url(). For content delivered through Studio CDN, media fields already carry absolute URLs (normalized on write), so these are only needed for the relative-path / local-file model.
Each takes an optional second argument that overrides the base baked in at generate time, for a host that reads its own runtime config per request instead — a Nuxt app, for one:
// composable or component
const { public: { contentrainMediaBaseUrl } } = useRuntimeConfig()
const cover = media(post.cover, contentrainMediaBaseUrl)A host that never bakes a base in at all — a Nuxt app reading useRuntimeConfig() per request, for one — has no media()/mediaBody() to call, since neither was generated. Import the underlying resolvers directly from @contentrain/query instead: resolveMediaUrl(value, base) and resolveMediaRefsInBody(markdown, base). Both are opt-in — no base (undefined, null, '') returns the value unchanged — so they are safe to call unconditionally, unlike media()/mediaBody() which must be generated first. The Astro loader (contentrainLoader({ mediaBaseUrl })) applies the same resolver automatically to image/video/file fields — including those nested inside object/array fields, such as an image gallery — markdown/richtext fields, and document bodies (titled embeds and inline HTML src/href included), since it reads .contentrain directly and nothing upstream has rewritten those references yet.
Relations
All model kinds support relation resolution via .include():
// Without include: raw relation ID
const raw = query('blog-post').locale('en').all()
// raw[0].author --> 'author-id-123' (string ID)
// With include: resolved object
const resolved = query('blog-post').locale('en').include('author', 'tags').all()
// resolved[0].author --> { id: '...', name: 'John', ... } (full object)Relations are resolved 1 level deep. Nested relations are not expanded.
Public Builds
By default the generated client and the Astro loader include every entry, drafts included, which suits an editorial preview. For a deployed site, filter by publication state:
| Option | CLI | Effect |
|---|---|---|
publishedOnly: true | --published | Only status: published entries inside their publish_at / expire_at window. Entries with no meta stay visible (legacy content) |
at: '<ISO>' | --at <ISO> | Evaluate the window at a fixed time, for a reproducible build. Implies publishedOnly |
requireStatus: true | --require-status | Also hide an entry with no status: no meta record, or one without status. Use it when every entry should carry one, as after a WordPress import. Implies publishedOnly, not at: the clock stays the build time |
// src/content.config.ts
import { defineCollection } from 'astro:content'
import { contentrainLoader } from '@contentrain/query/astro'
export const collections = {
posts: defineCollection({ loader: contentrainLoader({ model: 'posts', locale: 'en', requireStatus: true }) }),
}The window is evaluated when the site is built. A scheduled entry goes live at the first build after its publish_at, so a static site needs a build scheduled at each deadline.
Framework Setup
The #contentrain subpath import works natively in Node.js 22+. For browser bundlers, you need an alias.
// vite.config.ts
import { resolve } from 'node:path'
export default defineConfig({
resolve: {
alias: {
'#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'),
},
},
})// next.config.js
const path = require('path')
module.exports = {
webpack: (config) => {
config.resolve.alias['#contentrain'] =
path.resolve(__dirname, '.contentrain/client/index.mjs')
return config
},
}// nuxt.config.ts
export default defineNuxtConfig({
alias: {
'#contentrain': './.contentrain/client/index.mjs',
},
})// vite.config.ts (SvelteKit uses Vite internally)
import { resolve } from 'node:path'
export default defineConfig({
resolve: {
alias: {
'#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'),
},
},
})// metro.config.js
const path = require('path')
module.exports = {
resolver: {
extraNodeModules: {
'#contentrain': path.resolve(__dirname, '.contentrain/client/index.mjs'),
},
},
}# No alias needed!
# Node.js 22+ resolves #contentrain from package.json imports natively.For all bundler setups, also add a paths entry to tsconfig.json so the TypeScript language server resolves the alias:
{
"compilerOptions": {
"paths": {
"#contentrain": ["./.contentrain/client/index.d.ts"],
"#contentrain/*": ["./.contentrain/client/*.d.ts"]
}
}
}Framework Usage Patterns
| Framework | Pattern | Example |
|---|---|---|
| Nuxt 3 | useAsyncData | useAsyncData(() => singleton('hero').locale(locale).get()) |
| Next.js RSC | Direct call in server component | const data = singleton('hero').locale('en').get() |
| Astro | Frontmatter | const posts = query('blog-post').locale('en').all() |
| SvelteKit | +page.server.ts load | export const load = () => ({ hero: singleton('hero').locale('en').get() }) |
| Expo / RN | Direct call | const hero = singleton('hero').locale('en').get() |
| Node.js | Direct import | import { query } from '#contentrain' |
TypeScript Types
The generator produces exact TypeScript interfaces from your model schemas. For a model with fields title: string, order: integer, published: boolean:
// Generated in .contentrain/client/index.d.ts
export interface BlogPost {
id: string
title: string
order: number
published: boolean
}
// query('blog-post') returns QueryBuilder<BlogPost>
// Fully typed: .where() only accepts BlogPost field namesCommonJS Usage
The generated client is ESM (.contentrain/client/index.mjs). From a CommonJS file, reach it with a dynamic import() inside an async function:
// service.cjs — NestJS, Express, or any older tooling
async function loadContent() {
const { init } = await import('#contentrain')
const client = await init()
return client.singleton('hero').get()
}Not require(), and never a top-level await
await at the top level of a CommonJS file is a syntax error — the file does not load at all.
require('#contentrain') happens to work on Node 22.12 and newer, where require(esm) is unflagged, but it is flagged on earlier 22.x releases and it throws outright if the client module ever uses top-level await. await import() is correct on every version.
DOES NOT EXIST
Common mistakes to avoid:
These APIs do not exist
.filter()— use.where(field, value)or.where(field, op, value)instead.byId()— use.where('id', value).first()insteaddictionary().all()— use.get()insteadawait query(...)— local queries are synchronous, do not useawait.get()on QueryBuilder — use.all()or.first()
CDN Transport
For apps that fetch content from Contentrain Studio CDN instead of local files:
import { createContentrain } from '@contentrain/query/cdn'
const client = createContentrain({
projectId: '350696e8-...',
apiKey: 'crn_live_xxx',
})
// All CDN queries return Promises
const posts = await client.collection('faq').locale('en').all()
const hero = await client.singleton('hero').locale('en').get()
const t = await client.dictionary('ui').locale('en').get()
const doc = await client.document('docs').locale('en').bySlug('intro')CDN Documents — all() has no bodies
The CDN stores documents in two places, so all() and bySlug() return different things:
| Call | Reads | Returns |
|---|---|---|
all() / first() | documents/{model}/_index/{locale}.json | DocumentIndexEntry<T> — frontmatter only, no body |
bySlug(slug) | documents/{model}/{slug}/{locale}.json | { frontmatter: T, body, html } |
const q = client.document<GuideSection>('guide-sections').locale('tr')
// Listing: frontmatter only — one request.
const index = await q.sort('order', 'asc').all()
// Need the prose? One request per document.
const full = await Promise.all(index.map(s => q.bySlug(s.slug)))
full.map(d => renderMarkdown(d!.body))The bundled client differs
The local #contentrain client's document(...).all() does include bodies — it reads the content files directly. Reading .body off a CDN all() result is a compile error, so code written against bundled delivery fails to build rather than silently rendering empty pages after a CDN switch.
CDN Collection Operators
await client.collection('products')
.locale('en')
.where('price', 'lt', 100)
.where('category', 'in', ['electronics', 'accessories'])
.sort('price', 'asc')
.limit(20)
.all()Supported operators: eq, ne, gt, gte, lt, lte, in, contains.
CDN Entry Metadata
Enrich collection entries with status, publish/expire dates:
const posts = await client.collection('blog')
.locale('en')
.withMeta()
.all()
// posts[0]._meta → { status: 'published', publish_at: '...', expire_at: '...' }CDN Media
Access the media asset manifest and resolve variant URLs:
const media = client.media()
const assets = await media.list() // All assets with path + meta
const asset = await media.asset('hero.jpg') // Single asset
const url = media.url(asset, 'thumb') // Full CDN variant URL
asset.meta.width // 1920
asset.meta.blurhash // 'LEHV6nWB...'
asset.meta.alt // 'Hero image'CDN Forms
The browser side of Studio's public /api/forms/v1. No API key travels with these requests — the endpoints are unauthenticated by design and the public CORS allows only Content-Type.
const form = client.form()
const config = await form.config('contact')
// config.fields → { name: { type: 'string', required: true }, email: { type: 'email', … } } (exposed fields, keyed by id)
// config.captcha → 'turnstile' | null — mount the widget with config.captchaSiteKey
// config.honeypotField → '_hp' | null — render a hidden input with that name, leave it empty
const result = await form.submit('contact', {
name: 'Alice', email: 'alice@example.com',
}, { captchaToken: 'tok_xxx', honeypot: '' })
// { success: true, message } · { success: false, errors: [{ field, message }] }
// 402 / 403 / 404 / 429 reject with ContentrainError { status, code?, message }CDN Comments
The browser side of Studio's public /api/comments/v1. Only approved comments are returned, nested under their roots; no email, IP, user agent or referrer ever leaves the server. body is plain text — render it escaped.
const comments = client.comments()
const thread = await comments.thread('posts', entryId, { locale: 'en', page: 1, limit: 20, sort: 'oldest' })
// thread.config → { closed, requireApproval, requireEmail, maxDepth, maxBodyLength, captcha, captchaSiteKey, honeypotField }
// thread.comments → [{ id, parentId, depth, author: { name, url, isModerator }, body, type, createdAt, replies }]
const result = await comments.submit('posts', entryId, {
author: { name: 'Ada', email: 'ada@example.com' },
body: 'Great post',
parentId: thread.comments[0]?.id, // reply — omit for a root comment
}, { locale: 'en' })
// { success: true, status: 'pending' | 'approved', comment } · { success: false, errors: [{ field, message }] }CDN Conversation API
Send messages to the AI content agent for external content operations:
const conv = client.conversation()
// Send a message — returns complete response
const response = await conv.send('Create a blog post about Vue 4')
response.conversationId // 'conv-abc123'
response.message // 'I created the blog post...'
response.toolResults // [{ id, name, result }]
response.usage // { inputTokens, outputTokens }
// Continue conversation
await conv.send('Translate to Turkish', {
conversationId: response.conversationId,
})
// Fetch history
const history = await conv.history('conv-abc123', { limit: 50 })Conversation API Keys
Conversation API uses dedicated keys (crn_conv_* prefix) with per-key role, tool allowlist, model restrictions, and rate limits. Keys are managed in Studio workspace settings.
CDN Metadata Endpoints
const manifest = await client.manifest() // _manifest.json
const models = await client.models() // models/_index.json
const model = await client.model('faq') // models/faq.jsonBundle Preload
Opt-in mode that collapses per-render fetch waterfalls (N content requests + include resolution) into a single conditional GET. The transport fetches _bundle/{locale}.json — one artifact containing every JSON model for that locale — and serves all content, document-index, and dictionary reads from memory:
const client = createContentrain({
projectId: '350696e8-...',
apiKey: 'crn_live_xxx',
bundle: true, // or { revalidateMs: 30_000 }
})
// Optional eager warmup (e.g. SSR boot) — resolves true when a bundle was found
await client.preload('en')
// Zero network: served from the primed bundle
const posts = await client.collection('faq').locale('en').all()
const t = await client.dictionary('ui').locale('en').get()- Coverage — all JSON models (collections, singletons, dictionaries, document indexes) come from the bundle. Document bodies (
bySlug()), entry metadata (withMeta()), and media manifests keep using per-path fetch. - Revalidation — the bundle is re-checked after
revalidateMs(default60_000) with a conditional request; an unchanged bundle answers304at negligible cost. - Fallback — if the bundle is missing (
404), invalid, or the request fails, the transport transparently falls back to per-path fetching and retries afterrevalidateMs. Withbundleunset, behavior is identical to previous versions. - Non-i18n paths —
content/{model}/data.jsonentries ship in every locale bundle and are resolved viadefaultLocale(default'en').
Requires bundle publishing
The _bundle/{locale}.json artifact is produced by Studio CDN publishing. Until the project's first rebuild publishes a bundle, the SDK silently uses per-path fetching — enabling bundle: true early is safe.
CDN vs Local Comparison
| Aspect | Local (#contentrain) | CDN (createContentrain()) |
|---|---|---|
| Data source | Bundled .mjs files | HTTP fetch from Studio CDN |
| Return type | Sync (T[]) | Async (Promise<T[]>) |
| Auth | None | API key required |
| Caching | In-memory (embedded) | ETag-based HTTP cache + optional bundle preload |
| Use case | SSG, build-time | SSR, client-side, serverless, mobile |
Error Handling
import { ContentrainError } from '@contentrain/query'
try {
await client.collection('faq').locale('en').all()
} catch (err) {
if (err instanceof ContentrainError) {
console.log(err.status) // 401, 402, 403, 404, 429
console.log(err.code) // the API's machine code, when the body has one
console.log(err.message)
}
}When the workspace's subscription is inactive, forms and comments reject with 402 and code: 'payment_required'. The message is written for the workspace owner, not a visitor, and retrying does not help until the owner updates billing, so hide the form or thread:
import { isPaymentRequired } from '@contentrain/query'
try {
await form.submit('contact', values)
} catch (err) {
if (isPaymentRequired(err)) hideTheForm() // no retry, no message to the visitor
else showError(err)
}ContentrainError.code carries the API's machine code (data.code) whenever the error body has one. Branch on status or code, never on the message.
For Framework SDK Authors
The package root exports runtime primitives for building framework-specific SDKs:
import {
QueryBuilder,
SingletonAccessor,
DictionaryAccessor,
DocumentQuery,
createContentrainClient,
} from '@contentrain/query'
// Load the generated client dynamically
const client = await createContentrainClient(process.cwd())
const posts = client.query('blog-post').locale('en').all()The base SDK is framework-agnostic and MIT-licensed. Framework-specific integrations should build on top of these primitives without changing the underlying .contentrain/ contract.
Starter Templates
Every starter template comes with a pre-configured SDK client and content models:
| Template | Framework | Use Case |
|---|---|---|
| astro-blog | Astro | Blog / editorial |
| next-commerce | Next.js | E-commerce |
| nuxt-saas | Nuxt | SaaS marketing |
| sveltekit-editorial | SvelteKit | Editorial |
Related Pages
- CLI —
contentrain generatecommand that runs the SDK generator - MCP Tools — The tool layer that creates models and content the SDK consumes
- Rules — Agent guidance for content operations
- Skills — The
contentrain-sdkskill, and the embedded one this package ships - Contentrain Studio — Hosted team workflows and CDN delivery for non-web platforms
Embedded Agent Skill
This package ships an embedded Agent Skill at skills/contentrain-query/SKILL.md. AI coding agents can discover and load it for type-safe SDK usage guidance, including:
- QueryBuilder, SingletonAccessor, DictionaryAccessor, DocumentQuery APIs
- Local mode vs CDN mode differences
- Framework-specific bundler configuration (Vite, Next.js, Nuxt, SvelteKit, Metro)
The skill and its references are available via the @contentrain/query/skills/* subpath export.
Package Exports
| Export Path | Description |
|---|---|
@contentrain/query | Runtime classes + createContentrain() CDN factory + MediaAccessor + FormsClient + ConversationClient |
@contentrain/query/cdn | CDN transport: HttpTransport, async queries, MediaAccessor, FormsClient, ConversationClient |
@contentrain/query/generate | Programmatic generation API |
@contentrain/query/skills/* | Embedded Agent Skill (SKILL.md + references/) |