Show navigationHide navigation

Query API

The query() function provides a type-safe, chainable content selector for reading collection entries. It works in App Router server components, Pages Router getServerSideProps / getStaticProps, and API route handlers.

query is created by the createQuery() factory and bound to your app's config and types by the auto-generated cms/__generated__/query.ts.

Setup#

query is auto-generated into cms/__generated__/query.ts whenever you run npx octocms types:gen. It binds createQuery from the package to your app's config and generated EntryMap:

ts
// cms/__generated__/query.ts (auto-generated — do not edit)
import { createQuery } from 'octocms/query';
import { config, type OctoConfig } from 'octocms/config';
import type { EntryMap } from './types';

export const query = createQuery<EntryMap, OctoConfig>(config as unknown as OctoConfig);

Import query from cms/__generated__/query (the cms/__generated__ path alias resolves to ./cms/__generated__/):

ts
import { query } from 'cms/__generated__/query';

Basic usage#

ts
import { query } from 'cms/__generated__/query';

// All posts
const posts = await query('post').toArray();
//                         ^^^^  autocomplete: 'post' | 'item' | 'homePage' | ...

// Single entry
const home = await query('homePage').first();

// Access typed fields
posts[0].fields.title;       // string
posts[0].fields.publishedAt; // string | null
posts[0].fields.body;        // string

Entry status filtering#

query() automatically excludes entries with sys.status of draft or archived. Only published, changed, and merged entries are returned by default. This ensures drafts and soft-deleted entries never appear on public pages.

Legacy entries without a sys.status field are treated as merged.

To include all entries regardless of status (e.g. for Next.js Draft Mode previews), call .includeDrafts():

ts
const allPosts = await query('post').includeDrafts().toArray();

See nextjs-integration.md — Draft Mode for the full pattern.

Filtering#

Predicate function#

Full control with a callback:

ts
const published = await query('post')
  .filter((p) => p.fields.publishedAt !== null)
  .toArray();

Equality object#

Simple field-value matching:

ts
const enabled = await query('item')
  .filter({ enabled: 'true' })
  .toArray();

Combining filters#

Multiple .filter() calls are ANDed:

ts
const results = await query('item')
  .filter((e) => e.fields.enabled === 'true')
  .filter((e) => (e.fields.sortOrder ?? 0) > 1)
  .toArray();

Sorting#

ts
// Ascending (default)
const oldest = await query('post').sort('publishedAt').toArray();

// Descending
const newest = await query('post').sort('publishedAt', 'desc').toArray();

null values sort last regardless of direction.

Pagination#

skip / limit#

ts
const page2 = await query('post')
  .sort('publishedAt', 'desc')
  .skip(10)
  .limit(10)
  .toArray();

paginate()#

Returns { items, total, hasMore } for building pagination UI:

ts
const page = await query('item')
  .filter({ enabled: 'true' })
  .sort('sortOrder', 'asc')
  .skip(0)
  .limit(10)
  .paginate();

// page.items  — Entry<'item'>[]
// page.total  — number (all entries matching filters)
// page.hasMore — boolean

Methods#

MethodReturnsDescription
.conditions(conds)QueryBuilderSet branch selections for conditional fields
.filter(fn | obj)QueryBuilderAdd a filter (AND with previous filters)
.sort(field, dir?)QueryBuilderSort by field name, 'asc' (default) or 'desc'
.skip(n)QueryBuilderSkip first n results
.limit(n)QueryBuilderCap results at n entries
.includeDrafts()QueryBuilderInclude draft and archived entries (for preview/Draft Mode)
.toArray()Promise<Entry[]>Execute and return matching entries
.first()Promise<Entry | null>Execute and return first match or null
.paginate()Promise<{ items, total, hasMore }>Execute with pagination metadata

Usage by context#

query() works in any server-side Node.js context. For a comprehensive guide with all patterns, see nextjs-integration.md.

App Router — Server Component#

ts
// src/app/blog/page.tsx
import { query } from 'octocms/query';

export default async function BlogPage() {
  const posts = await query('post')
    .sort('publishedAt', 'desc')
    .toArray();

  return <ul>{posts.map((p) => <li key={p.sys.id}>{p.fields.title}</li>)}</ul>;
}

App Router — with "use cache"#

Co-locate the cached data fetcher with the page that uses it — define a private async helper at the top of the page file and mark it 'use cache'. Calls from both generateMetadata and the default export hit the same cache entry.

tsx
// app/<route>/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { OCTOCMS_PUBLIC_CONTENT_CACHE_TAG } from 'octocms/lib/publicContentCacheTag';
import { query } from 'cms/__generated__/query';

async function loadPageData() {
  'use cache';
  cacheTag(OCTOCMS_PUBLIC_CONTENT_CACHE_TAG);
  cacheLife('hours');
  return query('<your-collection>')
    .filter((entry) => /* filter */)
    .sort('<field>', 'desc')
    .toArray();
}

export default async function Page() {
  const items = await loadPageData();
  // …
}

A single shared cache tag covers every page that follows the pattern, so buildJsons() (admin save / create / delete) refreshes them all together.

App Router — generateStaticParams + generateMetadata#

ts
// src/app/blog/[slug]/page.tsx
import { query } from 'octocms/query';
import type { Metadata } from 'next';

export async function generateStaticParams() {
  const posts = await query('post')
    .filter((p) => p.fields.publishedAt != null)
    .toArray();
  return posts.map((p) => ({ slug: p.fields.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = await query('post').filter({ slug }).first();
  return { title: post?.fields.title ?? 'Not Found' };
}

App Router — Route Handler#

ts
// src/app/api/posts/route.ts
import { query } from 'octocms/query';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const page = Number(searchParams.get('page') ?? '1');
  const perPage = 10;

  const result = await query('post')
    .sort('publishedAt', 'desc')
    .skip((page - 1) * perPage)
    .limit(perPage)
    .paginate();

  return Response.json(result);
}

App Router — Layout#

ts
// src/app/layout.tsx
import { query } from 'octocms/query';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const homePage = await query('homePage').first();
  return (
    <html lang="en">
      <body>
        <header>{homePage?.fields.title}</header>
        <main>{children}</main>
      </body>
    </html>
  );
}

App Router — Draft Mode#

ts
import { draftMode } from 'next/headers';
import { query } from 'octocms/query';

export default async function BlogPage() {
  const { isEnabled } = await draftMode();
  const q = query('post').sort('publishedAt', 'desc');
  const posts = isEnabled
    ? await q.includeDrafts().toArray()
    : await q.filter((p) => p.fields.publishedAt != null).toArray();
  // ...
}

Pages Router — getStaticProps with ISR#

ts
// pages/blog.tsx
import { query } from 'octocms/query';

export async function getStaticProps() {
  const posts = await query('post')
    .sort('publishedAt', 'desc')
    .limit(20)
    .toArray();

  return { props: { posts }, revalidate: 3600 };
}

Pages Router — getStaticPaths#

ts
// pages/blog/[slug].tsx
import { query } from 'octocms/query';

export async function getStaticPaths() {
  const posts = await query('post')
    .filter((p) => p.fields.publishedAt != null)
    .toArray();
  return {
    paths: posts.map((p) => ({ params: { slug: p.fields.slug } })),
    fallback: 'blocking',
  };
}

Pages Router — getServerSideProps#

ts
// pages/blog.tsx
import { query } from 'octocms/query';

export async function getServerSideProps() {
  const posts = await query('post')
    .sort('publishedAt', 'desc')
    .limit(20)
    .toArray();

  return { props: { posts } };
}

Pages Router — API Route#

ts
// pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { query } from 'octocms/query';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const result = await query('post')
    .sort('publishedAt', 'desc')
    .limit(10)
    .paginate();
  res.json(result);
}

Type utilities#

The package exports several type utilities you can use directly:

TypeDescription
CollectionNames<C>Union of collection name strings
InferFields<C, Name>Typed fields object for a collection
InferEntry<C, Name>Full { sys, fields } entry type
ts
import type { OctoConfig } from 'octocms/config';
import type { InferEntry } from 'octocms/defineConfig';

type PostEntry = InferEntry<OctoConfig, 'post'>;
// { sys: { id: string; type: 'post' }; fields: { title: string; publishedAt: string | null; ... } }

For concrete, readable interfaces with fully resolved reference fields, use the auto-generated types in cms/__generated__/. See generated-types.md for full documentation.

ts
import type { PostEntry, AuthorEntry } from 'cms/__generated__';
import { CollectionName, ItemCategoryOption } from 'cms/__generated__';

The query() API uses these generated types internally, so return values already have resolved reference types (e.g., AuthorEntry[] instead of unknown). Run npx octocms types:gen after changing your schema to regenerate.

Content processing#

Each query() read resolves entries the same way as the rest of the stack:

  • Image fields: UUID values are resolved to a ResolvedImageField object: { src, alt, width, height, blurDataURL }. src is /media/<uuid>.<ext>; alt comes from the media entry title. See the image field docs for rendering examples.
  • Markdown fields: Content is read from companion .md files alongside the entry JSON (e.g. post-123.body.md). Returned as a plain string. If the companion file is missing, the field defaults to "".
  • Reference fields: Recursively resolved to full entry objects (deep nesting supported)
  • Other fields: Passed through as-is

Filtering: equality filters such as .filter({ featuredImage: someUuid }) no longer match processed entries. Use a predicate, e.g. .filter((p) => p.fields.featuredImage?.src.includes(uuid)), or compare alt / other fields as needed.

Caching on the sample public site#

query() has no built-in cache. Wrap it in a helper with "use cache", cacheTag(), and cacheLife() to enable caching and instant invalidation on save.

Cached helpers (getHomePage / getBlog / getPublishedPosts)Raw query(collection)
ReadsFixed collection(s) per helperAny collection; add .filter() / .sort() / .limit() as needed
Caching"use cache" + tagsNone unless you wrap it yourself
Type safetyInferred from query()Full autocomplete for collections and fields

Additional cached readers should use the same OCTOCMS_PUBLIC_CONTENT_CACHE_TAG unless you add your own invalidation. See the Tutorial for a complete cached helper example.