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#

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.

ts
export 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:

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