Querying Content
Fetch, filter, sort, and paginate your CMS entries from a Next.js frontend using the server client, HTTP client, or React hooks.
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
@airdraft/nexton npm —createCmsClient,createCmsHandler@airdraft/clienton npm — HTTP client for external apps@airdraft/reacton npm — React hooks@airdraft/react-contenton npm — display components@airdraft/contenton npm — pure-function content helpers- GitHub — aevrHQ/airdraft