Skip to content

Query SDK

npm version npm downloads

@contentrain/query is the consumption layer of the Contentrain ecosystem. It reads your model definitions and content files, then generates a fully typed JavaScript/TypeScript client that your application imports directly. Think of it like Prisma, but for content: define models, run generate, import and query.

Why a Generated Client?

You could read .contentrain/content/ files directly with fs.readFileSync. But that gives 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

bash
pnpm add @contentrain/query

Requirements:

  • Node.js 22+
  • A Contentrain project with .contentrain/config.json

Quick Start

bash
# Generate the client
npx contentrain generate

This 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

It also updates your package.json with subpath imports:

json
{
  "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:

ts
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).

ts
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
MethodSignatureReturnsDescription
localelocale(lang: string)thisSet the content locale
wherewhere(field, value)thisExact match filter on a field
sortsort(field, order?)thisSort by field, order is 'asc' or 'desc'
limitlimit(n: number)thisLimit number of results
offsetoffset(n: number)thisSkip first N results
includeinclude(...fields)thisResolve relation fields
allall()T[]Execute query, return all matches
firstfirst()T | undefinedExecute query, return first match

SingletonAccessor — Singletons

For singleton models (single entry, e.g. site settings, hero section).

ts
import { singleton } from '#contentrain'

const hero = singleton('hero')
  .locale('en')
  .include('featured_post')     // Resolve relations on singletons too
  .get()                        // Returns T
MethodSignatureReturnsDescription
localelocale(lang: string)thisSet the content locale
includeinclude(...fields)thisResolve relation fields
getget()TGet the singleton entry

DictionaryAccessor — Dictionaries

For dictionary models (flat key-value string maps, ideal for i18n).

ts
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"
MethodSignatureReturnsDescription
localelocale(lang: string)thisSet the content locale
getget()Record<string, string>Get all key-value pairs
getget(key)string | undefinedGet a single value by key
getget(key, params)stringGet 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).

ts
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
MethodSignatureReturnsDescription
localelocale(lang: string)thisSet the content locale
wherewhere(field, value)thisExact match filter
includeinclude(...fields)thisResolve relation fields
bySlugbySlug(slug)T | undefinedFind document by slug
allall()T[]Execute query, return all matches
firstfirst()T | undefinedExecute query, return first match

Relations

All model kinds support relation resolution via .include():

ts
// 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.

ts
// vite.config.ts
import { resolve } from 'node:path'

export default defineConfig({
  resolve: {
    alias: {
      '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'),
    },
  },
})
js
// next.config.js
const path = require('path')

module.exports = {
  webpack: (config) => {
    config.resolve.alias['#contentrain'] =
      path.resolve(__dirname, '.contentrain/client/index.mjs')
    return config
  },
}
ts
// nuxt.config.ts
export default defineNuxtConfig({
  alias: {
    '#contentrain': './.contentrain/client/index.mjs',
  },
})
ts
// vite.config.ts (SvelteKit uses Vite internally)
import { resolve } from 'node:path'

export default defineConfig({
  resolve: {
    alias: {
      '#contentrain': resolve(__dirname, '.contentrain/client/index.mjs'),
    },
  },
})
js
// metro.config.js
const path = require('path')

module.exports = {
  resolver: {
    extraNodeModules: {
      '#contentrain': path.resolve(__dirname, '.contentrain/client/index.mjs'),
    },
  },
}
bash
# 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:

json
{
  "compilerOptions": {
    "paths": {
      "#contentrain": ["./.contentrain/client/index.d.ts"]
    }
  }
}

Framework Usage Patterns

FrameworkPatternExample
Nuxt 3useAsyncDatauseAsyncData(() => singleton('hero').locale(locale).get())
Next.js RSCDirect call in server componentconst data = singleton('hero').locale('en').get()
AstroFrontmatterconst posts = query('blog-post').locale('en').all()
SvelteKit+page.server.ts loadexport const load = () => ({ hero: singleton('hero').locale('en').get() })
Expo / RNDirect callconst hero = singleton('hero').locale('en').get()
Node.jsDirect importimport { 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:

ts
// 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 names

CommonJS Usage

For legacy environments (NestJS, Express, older tooling):

js
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().length instead
  • dictionary().all() — use .get() instead
  • await query(...) — queries are synchronous, do not use await
  • .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:

ts
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.

  • CLIcontentrain generate command 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

Released under the MIT License.