Show navigationHide navigation
Next.js integration guide
query() works in any server-side Node.js context — App Router, Pages Router, Route Handlers, Server Actions, metadata functions, and more. One API, every context.
This guide covers every Next.js data-fetching pattern with code examples. For the full query() API reference, see query-api.md.
App Router#
Server Components#
The default and recommended way to fetch CMS content. Server Components are async by default and run on the server.
ts// src/app/blog/page.tsx import { query } from 'octocms/query'; export default async function BlogPage() { const posts = await query('post') .filter((p) => p.fields.publishedAt != null) .sort('publishedAt', 'desc') .toArray(); return ( <ul> {posts.map((p) => ( <li key={p.sys.id}>{p.fields.title}</li> ))} </ul> ); }
Server Components with caching#
Wrap each page's query() calls in a private async helper at the top of the page file, marked 'use cache', and tag it with OCTOCMS_PUBLIC_CONTENT_CACHE_TAG. Co-locating the fetcher with the page that uses it keeps each route self-contained, and a single shared tag covers every page so admin saves refresh them all together.
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(); // … }
generateMetadata can call the same helper — both hits land on the same cache entry.
generateStaticParams()#
Pre-render dynamic routes at build time. Returns all possible path parameters so Next.js can statically generate each page.
ts// src/app/blog/[slug]/page.tsx import { query } from 'octocms/query'; export async function generateStaticParams() { const posts = await query('post') .filter((p) => p.fields.publishedAt != null) .toArray(); return posts.map((post) => ({ slug: post.fields.slug, })); } export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await query('post') .filter({ slug }) .first(); // ... }
generateMetadata()#
Generate SEO metadata (title, description, Open Graph tags) from CMS content.
ts// src/app/blog/[slug]/page.tsx import type { Metadata } from 'next'; import { query } from 'octocms/query'; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const { slug } = await params; const post = await query('post').filter({ slug }).first(); if (!post) return { title: 'Not Found' }; return { title: post.fields.title, description: post.fields.body?.slice(0, 160), openGraph: { title: post.fields.title, images: post.fields.featuredImage ? [{ url: post.fields.featuredImage.src }] : [], }, }; }
Route Handlers#
Create JSON API endpoints for client-side fetching, webhooks, or external consumers.
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') .filter((p) => p.fields.publishedAt != null) .sort('publishedAt', 'desc') .skip((page - 1) * perPage) .limit(perPage) .paginate(); return Response.json(result); }
Response shape from .paginate():
json{ "items": [{ "sys": { "id": "...", "type": "post" }, "fields": { ... } }], "total": 42, "hasMore": true }
Server Actions#
Use query() inside Server Actions for form handling or mutations that need to read CMS data.
ts// src/app/actions.ts 'use server'; import { query } from 'octocms/query'; export async function getRelatedPosts(postId: string) { const posts = await query('post') .filter((p) => p.sys.id !== postId && p.fields.publishedAt != null) .sort('publishedAt', 'desc') .limit(3) .toArray(); return posts; }
Layouts#
Fetch shared data (navigation, site settings, footer) in a layout. The data is available to all child pages without re-fetching.
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(); const siteTitle = homePage?.fields.title ?? 'My Site'; return ( <html lang="en"> <body> <header>{siteTitle}</header> <main>{children}</main> </body> </html> ); }
Draft Mode#
Preview unpublished content (entries with sys.status of draft) before publishing.
Step 1: Create enable/disable API routes.
ts// src/app/api/draft/enable/route.ts import { draftMode } from 'next/headers'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const secret = searchParams.get('secret'); if (secret !== process.env.DRAFT_MODE_SECRET) { return new Response('Invalid secret', { status: 401 }); } const dm = await draftMode(); dm.enable(); const redirectTo = searchParams.get('redirect') ?? '/'; return Response.redirect(new URL(redirectTo, request.url)); }
ts// src/app/api/draft/disable/route.ts import { draftMode } from 'next/headers'; export async function GET() { const dm = await draftMode(); dm.disable(); return Response.redirect('/'); }
Step 2: Use .includeDrafts() when Draft Mode is active.
ts// src/app/blog/page.tsx 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(); return ( <> {isEnabled && <div>Draft Mode is ON</div>} <ul> {posts.map((p) => ( <li key={p.sys.id}> {p.fields.title} {p.sys.status === 'draft' && ' (draft)'} </li> ))} </ul> </> ); }
Pages Router#
query() works in all Pages Router data-fetching functions. They are plain async Node.js functions with full access to the filesystem and network.
getStaticProps#
Static generation with optional ISR (Incremental Static Regeneration).
ts// pages/blog.tsx import { query } from 'octocms/query'; import type { InferGetStaticPropsType } from 'next'; export async function getStaticProps() { const posts = await query('post') .filter((p) => p.fields.publishedAt != null) .sort('publishedAt', 'desc') .limit(20) .toArray(); return { props: { posts }, revalidate: 3600, // ISR: regenerate every hour }; } export default function BlogPage({ posts }: InferGetStaticPropsType<typeof getStaticProps>) { return ( <ul> {posts.map((p) => ( <li key={p.sys.id}>{p.fields.title}</li> ))} </ul> ); }
getStaticPaths#
Generate dynamic routes at build time.
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', // SSR pages not generated at build time }; } export async function getStaticProps({ params }: { params: { slug: string } }) { const post = await query('post').filter({ slug: params.slug }).first(); if (!post) return { notFound: true }; return { props: { post }, revalidate: 3600, }; }
getServerSideProps#
Server-side rendering on every request. Use when content must always be fresh.
ts// pages/dashboard.tsx import { query } from 'octocms/query'; export async function getServerSideProps() { const posts = await query('post') .sort('publishedAt', 'desc') .limit(10) .toArray(); return { props: { posts } }; }
Pages Router API Routes#
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 page = Number(req.query.page ?? '1'); const perPage = 10; const result = await query('post') .sort('publishedAt', 'desc') .skip((page - 1) * perPage) .limit(perPage) .paginate(); res.json(result); }
Client Components#
Client Components cannot call query() directly — it reads from the filesystem or GitHub API, which are server-only. Instead, fetch content through an API route.
API route + fetch#
ts// src/app/api/posts/route.ts (see Route Handlers section above) // src/app/components/PostSearch.tsx 'use client'; import { useState, useEffect } from 'react'; export function PostSearch() { const [posts, setPosts] = useState([]); useEffect(() => { fetch('/api/posts') .then((r) => r.json()) .then((data) => setPosts(data.items)); }, []); return <ul>{posts.map((p: any) => <li key={p.sys.id}>{p.fields.title}</li>)}</ul>; }
With SWR#
ts'use client'; import useSWR from 'swr'; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export function LatestPosts() { const { data, error, isLoading } = useSWR('/api/posts?page=1', fetcher); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error loading posts</div>; return ( <ul> {data.items.map((p: any) => ( <li key={p.sys.id}>{p.fields.title}</li> ))} </ul> ); }
Caching strategies#
"use cache" + cacheTag (App Router, recommended)#
The recommended approach for App Router. Content is cached and invalidated on-demand when entries are saved in the CMS.
ts'use cache'; import { cacheLife, cacheTag } from 'next/cache'; import { OCTOCMS_PUBLIC_CONTENT_CACHE_TAG } from 'octocms/lib/publicContentCacheTag'; import { query } from 'cms/__generated__/query'; export async function getPublishedPosts() { cacheTag(OCTOCMS_PUBLIC_CONTENT_CACHE_TAG); cacheLife('hours'); return query('post') .filter((p) => p.fields.publishedAt != null) .sort('publishedAt', 'desc') .toArray(); }
The CMS admin’s buildJsons() runs after saves and calls updateTag(OCTOCMS_PUBLIC_CONTENT_CACHE_TAG) plus revalidatePath('/', 'layout'), so all public readers that share that tag (and the tree under the root layout) refresh without a per-route config list.
ISR with revalidate (Pages Router)#
Time-based revalidation for Pages Router. Pages are regenerated in the background after the revalidate interval.
tsexport async function getStaticProps() { const posts = await query('post').sort('publishedAt', 'desc').toArray(); return { props: { posts }, revalidate: 3600 }; }
On-demand revalidation#
Trigger cache invalidation externally via the built-in revalidation endpoint:
GET /api/revalidate/octocms:content
This calls revalidateTag(tag, { expire: 0 }) for immediate cache expiry. Use it from:
- GitHub webhooks (on push to the published branch)
- CI/CD pipelines (after deployment)
- External services
Special pages#
Sitemap (app/sitemap.ts)#
ts// src/app/sitemap.ts import type { MetadataRoute } from 'next'; import { query } from 'octocms/query'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await query('post') .filter((p) => p.fields.publishedAt != null) .toArray(); const postEntries = posts.map((p) => ({ url: `${BASE_URL}/blog/${p.fields.slug}`, lastModified: new Date(p.fields.publishedAt!), changeFrequency: 'weekly' as const, priority: 0.7, })); return [ { url: BASE_URL, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 }, { url: `${BASE_URL}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.8 }, ...postEntries, ]; }
RSS feed#
ts// src/app/feed.xml/route.ts import { query } from 'octocms/query'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'; export async function GET() { const posts = await query('post') .filter((p) => p.fields.publishedAt != null) .sort('publishedAt', 'desc') .limit(20) .toArray(); const items = posts .map( (p) => ` <item> <title><![CDATA[${p.fields.title}]]></title> <link>${BASE_URL}/blog/${p.fields.slug}</link> <pubDate>${new Date(p.fields.publishedAt!).toUTCString()}</pubDate> <guid>${BASE_URL}/blog/${p.fields.slug}</guid> </item>`, ) .join(''); const xml = `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> <channel> <title>My Blog</title> <link>${BASE_URL}</link> <description>Latest posts</description> <atom:link href="${BASE_URL}/feed.xml" rel="self" type="application/rss+xml"/> ${items} </channel> </rss>`; return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }, }); }
OG image generation#
Dynamic social share images using CMS content. Must use Node.js runtime (not Edge).
tsx// src/app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og'; import { query } from 'octocms/query'; export const runtime = 'nodejs'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await query('post').filter({ slug }).first(); return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 60, backgroundColor: '#fff' }}> <div style={{ fontSize: 64, fontWeight: 'bold' }}>{post?.fields.title ?? 'Blog Post'}</div> </div> ), { ...size }, ); }
robots.ts#
ts// src/app/robots.ts import type { MetadataRoute } from 'next'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/', disallow: '/cms/' }, sitemap: `${BASE_URL}/sitemap.xml`, }; }
Limitations#
Edge Runtime#
query() does not work in Edge Runtime. It depends on fs/promises and path, which are unavailable in the Edge environment.
If you need a Route Handler at the Edge, set runtime = 'nodejs' explicitly:
tsexport const runtime = 'nodejs'; // required for query()
Middleware#
Next.js Middleware runs in Edge Runtime. Do not call query() from middleware.ts. Use Middleware only for auth checks, redirects, and request rewriting — not content fetching.
Client Components#
Client Components run in the browser and cannot access the server filesystem or GitHub API. To read CMS content from a Client Component, create an API Route Handler and fetch from it. See the Client Components section above.