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/localeIt 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"
}
}
}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')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 | Exact match filter on a field |
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 |
all | all() | T[] | Execute query, return all matches |
first | first() | T | undefined | Execute query, return first match |
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 | Exact match filter |
include | include(...fields) | this | Resolve relation fields |
bySlug | bySlug(slug) | T | undefined | Find document by slug |
all | all() | T[] | Execute query, return all matches |
first | first() | T | undefined | Execute query, return first match |
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.
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"]
}
}
}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
For legacy environments (NestJS, Express, older tooling):
const clientModule = require('#contentrain')
const client = await clientModule.init()
const hero = client.singleton('hero').get()DOES NOT EXIST
Common mistakes to avoid:
These APIs do not exist
.filter()— use.where(field, value)instead.byId()— use.where('id', value).first()instead.count()— use.all().lengthinsteaddictionary().all()— use.get()insteadawait query(...)— queries are synchronous, do not useawait.where('field', 'eq', value)— just.where('field', value), no operator.get()on QueryBuilder — use.all()or.first()
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. Community-built framework SDKs (e.g., @contentrain/nuxt, @contentrain/next) can extend these primitives.
Related Pages
- CLI —
contentrain generatecommand that runs the SDK generator - MCP Tools — The tool layer that creates models and content the SDK consumes
- Rules & Skills — Agent guidance for content operations
- Contentrain Studio — Content CDN for non-web platforms (iOS, Android, Flutter) that can't import from Git at runtime