All guides

Querying Content

Fetch, filter, sort, and paginate your CMS entries from a Next.js frontend using the server client, HTTP client, or React hooks.

2 min read280 words

Airdraft serves content over a standard REST API. You can query it with a direct server client (bypasses HTTP entirely — ideal for RSC), the typed HTTP client, React hooks, or plain fetch.

Option 1 — Server client (RSC / server-side, recommended)

@airdraft/next exports createCmsClient, which calls the CMS engine directly without an HTTP round-trip. Use it in React Server Components, generateStaticParams, API routes, and generateMetadata.

// lib/cms.ts
import { createCmsClient } from '@airdraft/next'
import airdraft from '@/airdraft.config'

export const cms = createCmsClient(airdraft)

List entries

const result = await cms.listEntries('posts', {
  status: 'published',
  limit: 10,
  page: 1,
  sort: { field: 'publishedAt', order: 'desc' },
})

// result.entries    → RichEntry[]
// result.total      → total count
// result.page       → current page number
// result.pages      → total page count
// result.hasNext    → boolean
// result.hasPrev    → boolean

Get a single entry

const entry = await cms.getEntry('posts', 'my-first-post')
// Returns null if not found

if (!entry) notFound()

// entry.data        → { title, body, cover, ... }
// entry.sha         → content fingerprint for optimistic updates
// entry.wordCount   → number
// entry.readTime    → '3 min read'
// entry.prev / entry.next  → sibling stubs (when siblings: true)

Sibling navigation

const entry = await cms.getEntry('posts', 'my-post', {
  siblings: true,  // or pass { sort, filter, fields } for fine-grained control
})
// entry.prev → { slug, data: { title }, wordCount, readTime }
// entry.next → { slug, data: { title }, wordCount, readTime }

Filter entries

const result = await cms.listEntries('posts', {
  filter: {
    tags: { $contains: 'nextjs' },
    publishedAt: { $lte: new Date().toISOString() },
  },
})

Available filter operators:

Operator Meaning
(none) Exact match ($eq)
$contains Case-insensitive substring match (string) or item-in-array check
$gt / $gte Greater than / greater than or equal
$lt / $lte Less than / less than or equal
$in Value is one of an array of options

Expand relations

const entry = await cms.getEntry('posts', 'my-post', { expand: 'author' })
// entry.data.author → { slug: 'jane-doe', data: { name: 'Jane Doe', avatar: '...' } }

Option 2 — HTTP client (any environment)

@airdraft/client is a typed HTTP client. Use it when you're querying Airdraft from outside the Next.js app (external services, build scripts, CI, mobile clients).

npm install @airdraft/client
// lib/cms.ts
import { AirdraftClient } from '@airdraft/client'

export const client = new AirdraftClient({
  apiUrl: process.env.CMS_API_URL!,
  auth: { type: 'apiKey', token: process.env.CMS_API_KEY! },
})
// List entries
const result = await client.entries.list('posts', {
  status: 'published',
  limit: 10,
  page: 1,
})
// result.data   → entry objects
// result.meta   → { total, page, pages, hasNext, hasPrev }

// Single entry
const entry = await client.entries.get('posts', 'my-first-post')
// entry.data   → field values
// entry.meta   → { sha, wordCount, readTime, prev, next }

Error handling:

import { AirdraftClientError } from '@airdraft/client'

try {
  await client.entries.update('posts', 'my-post', updatedData, oldSha)
} catch (err) {
  if (err instanceof AirdraftClientError) {
    console.log(err.code)       // 'CONFLICT', 'VALIDATION_ERROR', etc.
    console.log(err.statusCode) // 409, 422, etc.
    console.log(err.details)    // per-field errors for VALIDATION_ERROR
  }
}

Option 3 — React hooks (client components)

@airdraft/react provides SWR-style hooks. Use them in client components for interactive UIs.

npm install @airdraft/react

Wrap your app (or layout) with AirdraftProvider:

// app/layout.tsx
import { AirdraftProvider } from '@airdraft/react'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AirdraftProvider apiUrl={process.env.NEXT_PUBLIC_CMS_API_URL!}>
          {children}
        </AirdraftProvider>
      </body>
    </html>
  )
}

useEntries

'use client'
import { useEntries } from '@airdraft/react'

export function PostList() {
  const { entries, total, loading, error, refresh } = useEntries('posts', {
    status: 'published',
    limit: 10,
    page: 1,
    sort: 'publishedAt',
    order: 'desc',
  })

  if (loading) return <p>Loading…</p>
  if (error) return <p>Failed to load posts.</p>

  return (
    <ul>
      {entries.map((post) => (
        <li key={post.slug}>{post.data.title as string}</li>
      ))}
    </ul>
  )
}

useEntry

'use client'
import { useEntry } from '@airdraft/react'

export function PostDetail({ slug }: { slug: string }) {
  const { entry, loading } = useEntry('posts', slug)
  if (loading || !entry) return null
  return <h1>{entry.data.title as string}</h1>
}

usePagination

'use client'
import { useEntries, usePagination } from '@airdraft/react'

export function PaginatedPosts() {
  const { page, limit, setPage } = usePagination({ defaultLimit: 10 })
  const { entries, total } = useEntries('posts', { page, limit })
  const totalPages = Math.ceil((total ?? 0) / limit)

  return (
    <>
      {entries.map((p) => <div key={p.slug}>{p.data.title as string}</div>)}
      <button disabled={page <= 1} onClick={() => setPage(page - 1)}>Prev</button>
      <span>{page} / {totalPages}</span>
      <button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>Next</button>
    </>
  )
}

Option 4 — Raw HTTP API

The Airdraft REST API is framework-agnostic:

GET /api/cms/posts?status=published&limit=10&sort[field]=publishedAt&sort[order]=desc
X-API-Key: ntk_xxxxxxxxxxxxxxxx

Response:

{
  "data": [
    {
      "slug": "my-first-post",
      "status": "published",
      "publishedAt": "2026-06-28T10:00:00.000Z",
      "title": "My First Post",
      "body": "<p>Hello world!</p>"
    }
  ],
  "meta": {
    "total": 1,
    "page": 1,
    "pages": 1,
    "hasNext": false,
    "hasPrev": false,
    "offset": 0,
    "limit": 10
  }
}

Single entry:

GET /api/cms/posts/my-first-post?expand=author&siblings=true
{
  "data": { "title": "My First Post", "author": { "slug": "jane-doe", "data": { "name": "Jane Doe" } } },
  "meta": {
    "sha": "abc123...",
    "wordCount": 432,
    "readTime": "2 min read",
    "prev": { "slug": "previous-post", "data": { "title": "Previous Post" } },
    "next": null
  }
}

Rendering rich-text body content

rich-text fields return HTML strings. Use @airdraft/react-content for ready-made display components:

import { BodyField, DateField, ImageField } from '@airdraft/react-content'

export default async function PostPage({ params }) {
  const entry = await cms.getEntry('posts', params.slug)

  return (
    <article>
      <h1>{entry.data.title as string}</h1>
      <DateField value={entry.data.publishedAt as string} />
      <ImageField entry={entry} field="cover" />
      <BodyField value={entry.data.body as string} />
    </article>
  )
}

ImageField and MediaField read the {field}_url / {field}_media companions injected by @airdraft/plugin-media — no baseUrl needed when using the server client.

Resources

What's next