Quiverblocks chooses Diggama to deliver 200+ articles every month

Tutorial · 45 min read

Next.js headless CMS tutorial: build it with Claude Code or Codex

TL;DR

To run a Next.js App Router site on a headless CMS, read content in Server Components through one typed client that tags every cached fetch, prerender pages with generateStaticParams, and expose a secret-protected route handler that calls revalidateTag when the CMS sends a webhook. On Cloudflare Workers, deploy with the @opennextjs/cloudflare adapter and give it an R2 incremental cache, a D1 tag cache and a Durable Object queue, otherwise on-demand revalidation has nowhere to store its state. Any AI coding agent, whether Claude Code, Codex, Cursor or another, can write each step from a short prompt once AGENTS.md holds the project rules and the agent is connected to the Diggama MCP server.

By Diggama Team Updated Tested with Next.js 16.3.8React 19.3.0@opennextjs/cloudflare 1.20.9Wrangler 4.148.0Tailwind CSS 4.3.3TypeScript 5.9.3Node.js 22.22 View as Markdown

This tutorial builds Northwind Studio, the demo site of the series, with Next.js and the App Router: a homepage, a blog, a team page and a contact form, with every word and image coming from Diggama. It is the Next.js version of the pillar guide, which explains the architecture and the reasons behind it. If your site is mostly pages and posts and will stay that way, the Astro tutorial builds the same site with fewer moving parts. The CMS here is Diggama; if you are still comparing, the best headless CMS in 2026 covers the alternatives, and the architecture carries over to any API-first CMS.

Each step has a prompt to give your AI coding agent and the complete file it should produce. The prompts work the same in Claude Code, OpenAI Codex, Cursor, Gemini CLI and GitHub Copilot agent mode; only the setup in Step 2 differs per tool. We built every file below, ran next build and the Cloudflare build against a local mock of the Diggama API, then ran the Worker locally to check pages, the contact form, revalidation and Draft Mode.

What are you building in this Next.js headless CMS tutorial?

You are building a Next.js site whose pages are prerendered from Diggama content, cached on Cloudflare, and refreshed within seconds when an editor publishes. Nothing is hardcoded except navigation, form labels and the legal footer, so the marketing team edits the site in Diggama without a developer.

RouteContent sourceRenderingUpdated by
/homepage (singular) and the latest postsStatic, cachedWebhook to /api/revalidate
/blogpostsStatic, cachedWebhook to /api/revalidate
/blog/[slug]One record of postsStatic per post, new posts rendered on first visitWebhook to /api/revalidate
/teamteam-membersStatic, cachedWebhook to /api/revalidate
/contactForm, writes to contact-submissionsStatic page, Server ActionVisitors
/api/revalidate, /api/draftNoneRoute handlersDiggama Workflows, editors

The difference with the Astro version is where the refresh happens. Astro rebuilds the whole site on every publish. Next.js keeps a cache on the server and throws away only the tagged entries, so a publish costs one request to your Worker instead of a full build.

What you need before you start:

  • Node.js 22 or later, on macOS, Linux or WSL (OpenNext does not guarantee Windows support)
  • An AI coding agent, installed and signed in (see the table below)
  • A Diggama project (3-month free trial, no credit card)
  • A Cloudflare account and a GitHub or GitLab repository
AgentInstallPlan
Claude Codenpm install -g @anthropic-ai/claude-code, then claude (setup)Pro ($20/month) or higher; the free claude.ai plan does not include it (pricing)
OpenAI Codexnpm install -g @openai/codex, then codex and Sign in with ChatGPT (CLI docs)Included in ChatGPT plans, Plus is $20/month (pricing)
CursorThe editor, or the agent CLI (install)Pro from $20/month; the free Hobby plan has limited agent requests (pricing)
Gemini CLInpm install -g @google/gemini-cli, then gemini (README)Free tier with a Google account (quotas)
GitHub CopilotVS Code with Copilot, chat in agent modeFree tier with limited agent use, Pro $10/month (plans)

Which versions does this tutorial use, and why not the latest Next.js?

The code was tested with Next.js 16.3.8, React 19.3.0, @opennextjs/cloudflare 1.20.9 and Wrangler 4.148.0. We pinned Next.js one minor version back on purpose: Next.js 16.4.0 was published on 6 October 2026, and with the current adapter every page served by the Worker failed with a 500 error when we ran it locally with npm run preview.

The error in the Worker log was Unexpected loadManifest(/.next/server/preview-props.json) call!. Next.js 16.4.0 writes a new preview-props.json file that the adapter does not bundle yet. Version 16.3.8 does not produce that file, and everything in this tutorial works with it. The adapter has fixed the same kind of gap quickly in the past (Next.js 16.2 added prefetch-hints.json and a patch release followed), so check the OpenNext releases before you upgrade, then run npm run preview and load a page.

A note on the adapter choice. Cloudflare’s Next.js guide now recommends vinext, which it describes as “a Vite plugin that reimplements the Next.js API surface” and as beta. OpenNext runs the output of the real next build, and its documentation states that all minor and patch versions of Next.js 16 are supported (16.4.0 still failed in our test, as explained above, so trust npm run preview over the support matrix). This tutorial uses OpenNext because it is the stable, 1.x path for an App Router app today. The application code is plain Next.js, so moving to vinext later is mostly a change of build tooling.

Step 1: How do you set up the Diggama blueprints and tokens?

Create four blueprints with the exact field keys below, then create one token per job. The code reads attributes['hero-title'], so a field named differently renders nothing, with no error.

In the Diggama dashboard, open Configuration › Blueprints and create:

BlueprintTypeFields (key: type)
homepageSingular resourcehero-title: Text, hero-subtitle: Text, hero-image: Image, intro: Rich text
postsResourcetitle: Text (required), slug: Slug, excerpt: Text, cover: Image, content: Rich text
team-membersResourcename: Text (required), role: Text, photo: Image, bio: Rich text
contact-submissionsRead-only resourcename: Text, email: Email, message: Text

Three facts from the blueprints documentation shape the code. Field keys are derived from the field name (“Hero title” becomes hero-title) and cannot be changed afterwards. Image fields come back as absolute URL strings and rich text as an HTML string. A singular resource is served by the list endpoint as a one-item list, so the code reads data[0].

Posts have no date field. Every record carries a system published_at that is null for a draft, a future date when scheduled, and a past date once published.

Then open Configuration › API Tokens and create the tokens. Each token is scoped per blueprint and per ability:

TokenAbilitiesBlueprintsEnvironment variableNeeded at
Siteviewhomepage, posts, team-membersDIGGAMA_TOKENBuild and runtime
Formcreatecontact-submissionsDIGGAMA_FORM_TOKENRuntime
Previewpreviewhomepage, posts, team-membersDIGGAMA_PREVIEW_TOKENRuntime, Draft Mode only
Your AI agent (MCP)view, preview, create, updateAll four (Read & write preset)None; created in Step 2 and kept out of the repositoryYour machine

The “Needed at” column is the main difference with a static site. Next.js regenerates pages on the Worker after a revalidation, so the site token must be available at runtime, not only during the build.

Diggama plans include 1, 3 or 5 API keys (Starter, Growth, Enterprise; see pricing), and every plan includes the MCP server and Workflows. With fewer keys than jobs, combine them: one server token with view on the three content blueprints and create on contact-submissions still cannot edit or delete anything, because abilities are per blueprint. Set it as both DIGGAMA_TOKEN and DIGGAMA_FORM_TOKEN, and the code works unchanged.

Step 2: How do you create the Next.js app and prepare your AI coding agent?

Scaffold the app with create-next-app, pin Next.js to the tested version, add the Cloudflare adapter, then write the project rules into AGENTS.md and connect your agent to the Diggama MCP server. Do this before the first prompt that touches content, or your agent will hardcode copy because it has no other source.

npx create-next-app@latest northwind-studio --ts --tailwind --app --no-src-dir \
  --import-alias "@/*" --use-npm --no-react-compiler --no-cache-components \
  --no-eslint --no-biome --yes
cd northwind-studio
npm install --save-exact [email protected]
npm install @opennextjs/cloudflare@latest
npm install --save-dev wrangler@latest

create-next-app 16.4 writes an AGENTS.md file. Its generated block, between the <!-- BEGIN:nextjs-agent-rules --> and <!-- END:nextjs-agent-rules --> markers, tells coding agents to read the Next.js documentation bundled in node_modules/next/dist/docs/ instead of relying on memory. Leave that block in place and append the site’s rules below it:

cat >> AGENTS.md <<'EOF'

## Northwind Studio website

Company website: Next.js App Router, deployed to Cloudflare Workers with
@opennextjs/cloudflare. All content lives in Diggama (headless CMS).

### Commands
- `npm run dev`: local dev server (reads .env.local)
- `npm run build`: Next.js production build (fetches content from Diggama)
- `npm run preview`: build for Cloudflare and run it locally in the Workers runtime
- Do not run `npm run deploy`. Deploys go through Workers Builds on push.

### Content rules (non-negotiable)
- Never hardcode user-facing copy, images or lists in components. Every headline,
  paragraph, image, post and team member comes from Diggama.
- All Diggama calls go through `lib/diggama.ts`. Pages never call fetch() on the API.
- Server Components only for Diggama reads. Never import `lib/diggama.ts` from a
  file with 'use client', and never expose a token through a NEXT_PUBLIC_ variable.
- Allowed hardcoded strings: navigation labels, form labels and messages, legal footer.
- If a page needs content that has no blueprint field yet, stop and tell me which
  field to add. Do not invent a fallback string.

### Diggama
- API: https://api.diggama.com/v2 (override with DIGGAMA_API_URL), header
  `Authorization: Bearer <token>`
- Blueprints: `homepage` (singular), `posts`, `team-members`,
  `contact-submissions` (read-only, written by the contact Server Action only)
- Field keys use hyphens: read them as `attributes['hero-title']`.
- Images are absolute URLs; rich text fields are HTML strings (render with
  components/RichText.tsx).
- Every cached fetch is tagged `diggama` and `diggama:<blueprint>`.
  /api/revalidate expires them. A new page that reads Diggama must use the
  functions in lib/diggama.ts so it gets the tags.
- Pages pass `{ draft }` from `draftMode()` to every lib/diggama.ts call.
- Use the `diggama` MCP server to check field keys before writing code against a
  blueprint. Do not guess keys.

### Environment variables (server only)
- `DIGGAMA_TOKEN`: view token, build time and runtime
- `DIGGAMA_PREVIEW_TOKEN`: preview token, Draft Mode only
- `DIGGAMA_FORM_TOKEN`: create-only token on contact-submissions
- `REVALIDATE_SECRET`, `DRAFT_SECRET`: shared secrets for /api/revalidate and /api/draft
EOF

One file serves every major agent. Only Gemini CLI needs a setting, and Claude Code needs care if the project also has a CLAUDE.md:

AgentReads the project rules fromExtra step
Claude CodeAGENTS.md, natively since v2.1.277, but only when no CLAUDE.md or CLAUDE.local.md existsOn an older version, or if the project has a CLAUDE.md, put @AGENTS.md on the first line of CLAUDE.md
OpenAI CodexAGENTS.md (native)None
CursorAGENTS.md (native)None
GitHub Copilot (agent mode)AGENTS.md (chat.useAgentsMdFile, on by default)None
Gemini CLIGEMINI.md (not AGENTS.md by default)Add { "context": { "fileName": ["AGENTS.md", "GEMINI.md"] } } to .gemini/settings.json

For a longer, annotated version of these rules and the reasoning behind each one, see the AGENTS.md template for websites.

Connect your agent to Diggama over MCP

Diggama’s MCP server lets your agent read the real blueprints instead of guessing field keys. In the dashboard, open Configuration › Connect to AI, create a connection with the Read & write preset (view, preview, create, update: the agent sees drafts, and what it writes stays a draft), and copy the token, which is shown once. A preset only covers the blueprints that exist when you create the connection, so create the four blueprints first; if you add one later, edit the token on the API Tokens page and reconnect the agent.

Then register the server in your agent. Every setup below keeps the token out of the repository: in your home directory, in an environment variable, or in the editor’s secret storage.

AgentWhere the config livesHow to check
Claude Code~/.claude.json with --scope user/mcp in a session
OpenAI Codex (CLI, IDE extension, ChatGPT desktop app)~/.codex/config.toml, shared by the threecodex mcp list or /mcp
Cursor (editor and agent CLI)~/.cursor/mcp.json, shared by bothagent mcp list, or Customize in the sidebar
Gemini CLI~/.gemini/settings.json with --scope usergemini mcp list or /mcp
GitHub Copilot in VS Code.vscode/mcp.json, token in VS Code’s secret storageMCP: List Servers

Claude Code

Per the Claude Code MCP docs:

claude mcp add --transport http diggama https://api.diggama.com/mcp \
  --scope user \
  --header "Authorization: Bearer YOUR_CONNECTION_TOKEN"

OpenAI Codex

Codex stores the name of an environment variable and sends its value as the bearer token, so the token never reaches a config file. Put the export line in your shell profile, so the variable is set in the environment Codex starts from.

export DIGGAMA_MCP_TOKEN=YOUR_CONNECTION_TOKEN
codex mcp add diggama --url https://api.diggama.com/mcp --bearer-token-env-var DIGGAMA_MCP_TOKEN

The variable is named DIGGAMA_MCP_TOKEN, not DIGGAMA_TOKEN, so it cannot be confused with the site token in .env.local. Run this tutorial in the CLI, the IDE extension or the desktop app: Codex cloud tasks have no documented MCP support.

Cursor

The user-level file applies to every project and to the agent CLI (Cursor MCP docs):

~/.cursor/mcp.json
{
  "mcpServers": {
    "diggama": {
      "url": "https://api.diggama.com/mcp",
      "headers": { "Authorization": "Bearer YOUR_CONNECTION_TOKEN" }
    }
  }
}

Gemini CLI

Per the Gemini CLI MCP docs; without --scope user, the command writes to .gemini/settings.json in the project:

gemini mcp add --transport http --scope user \
  --header "Authorization: Bearer YOUR_CONNECTION_TOKEN" diggama https://api.diggama.com/mcp

GitHub Copilot in VS Code

VS Code prompts for the token once and stores it, so this file holds no secret and can be committed (VS Code MCP configuration):

.vscode/mcp.json
{
  "inputs": [
    { "type": "promptString", "id": "diggama-token", "description": "Diggama API token", "password": true }
  ],
  "servers": {
    "diggama": {
      "type": "http",
      "url": "https://api.diggama.com/mcp",
      "headers": { "Authorization": "Bearer ${input:diggama-token}" }
    }
  }
}

VS Code now prefers a portable .mcp.json for new servers, but the ${input:...} password prompt is documented for this format. Chat apps such as ChatGPT and claude.ai are not a fit for this tutorial, which needs an agent that edits files and runs builds. The MCP guide covers them, every Diggama tool and the write safeguards.

Configure Next.js

Prompt for your AI agent
Update next.config.ts: keep the existing turbopack rule for Tailwind, set images.unoptimized to true because Diggama image fields are absolute URLs we serve as they are, and call initOpenNextCloudflareForDev() from @opennextjs/cloudflare after the default export. Explain each option in a comment.
next.config.ts
import type { NextConfig } from 'next';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';

const nextConfig: NextConfig = {
  images: {
    // Diggama image fields are absolute URLs, served as they are. To resize
    // them on Cloudflare, add the IMAGES binding and remotePatterns instead.
    unoptimized: true,
  },
  turbopack: {
    rules: {
      '*.css': {
        loaders: ['@tailwindcss/turbopack'],
        as: '*.css',
      },
    },
  },
};

export default nextConfig;

// Gives `next dev` access to Cloudflare bindings (R2, D1...) through Wrangler.
initOpenNextCloudflareForDev();

images.unoptimized keeps next/image (lazy loading, fill, sizes) without routing images through an optimizer. On Cloudflare, OpenNext optimizes images through the Cloudflare Images binding, which can incur additional costs. If you want resized images, add the IMAGES binding to wrangler.jsonc and a remotePatterns entry for the host you see in an image URL returned by your project, then remove unoptimized.

Step 3: How do you write a typed Diggama client with caching and pagination?

Put every Diggama call in one file, lib/diggama.ts, that types each blueprint, pages through lists, and tags every cached fetch. The tags are what makes on-demand revalidation work later: revalidateTag('diggama') can only expire responses that were tagged diggama when they were fetched.

Prompt for your AI agent
Use the diggama MCP server to describe the blueprints homepage, posts, team-members and contact-submissions. Then create lib/diggama.ts:
- import 'server-only' at the top
- API base from DIGGAMA_API_URL, defaulting to https://api.diggama.com/v2
- TypeScript types for each blueprint's attributes with the exact field keys from the MCP output
- a get() helper that sends Authorization: Bearer DIGGAMA_TOKEN, uses cache: 'force-cache' with next.tags ['diggama', 'diggama:<blueprint>'], and throws with status and URL when the response is not OK
- an options argument { draft } that switches to DIGGAMA_PREVIEW_TOKEN with cache: 'no-store' and drops the published filter
- list endpoints request filter[published]=true and page through all results with per_page=100 until meta.last_page
- the singular homepage is read from data[0] without the published filter
- exports getHomepage, getPosts (sorted by -published_at), getPost(slug) using filter[slug][eq], getTeamMembers (sorted by name)
- createContactSubmission(attributes) that POSTs {"attributes": ...} with DIGGAMA_FORM_TOKEN, never cached, and maps a 422 VALIDATION_ERROR to field errors
Do not create any page yet.

Create .env.local for development (it is ignored by Git through the .env* rule create-next-app adds):

.env.local
# Optional: point to another API base, for example a local mock
# DIGGAMA_API_URL=https://api.diggama.com/v2
DIGGAMA_TOKEN=your-view-token
DIGGAMA_PREVIEW_TOKEN=your-preview-token
DIGGAMA_FORM_TOKEN=your-create-only-token
REVALIDATE_SECRET=a-long-random-string
DRAFT_SECRET=another-long-random-string

Generate the two secrets with openssl rand -hex 32. Then the client:

lib/diggama.ts
import 'server-only';

// Every call to Diggama goes through this file. Pages import the functions
// below and never call fetch() on the API themselves.
const API_URL = process.env.DIGGAMA_API_URL ?? 'https://api.diggama.com/v2';

// Cache tags. Every cached response carries the broad tag plus the tag of
// its blueprint, so /api/revalidate can expire everything or one blueprint.
export const CACHE_TAG = 'diggama';
export const BLUEPRINTS = ['homepage', 'posts', 'team-members'] as const;
export type Blueprint = (typeof BLUEPRINTS)[number];
export const blueprintTag = (blueprint: Blueprint) => `diggama:${blueprint}`;

export type DiggamaResource<A> = {
  id: string;
  type: 'resource';
  published_at: string | null;
  created_at: string;
  updated_at: string;
  attributes: A;
};

type ListResponse<A> = {
  data: DiggamaResource<A>[];
  meta: {
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
    from: number | null;
    to: number | null;
  };
  links: { first: string; last: string; prev: string | null; next: string | null };
};

// Attribute keys are the field keys Diggama derived from the field names.
// Image fields come back as absolute URLs, rich text as HTML strings.
export type Homepage = {
  'hero-title': string | null;
  'hero-subtitle': string | null;
  'hero-image': string | null;
  intro: string | null;
};

export type Post = {
  title: string;
  slug: string;
  excerpt: string | null;
  cover: string | null;
  content: string | null;
};

export type TeamMember = {
  name: string;
  role: string | null;
  photo: string | null;
  bio: string | null;
};

export type ContactSubmission = {
  name: string;
  email: string;
  message: string;
};

// draft: true is set by pages when Next.js Draft Mode is on. It switches to
// the preview token, which also sees drafts and scheduled records.
type ReadOptions = { draft?: boolean };

async function get<T>(blueprint: Blueprint, params: Record<string, string>, { draft = false }: ReadOptions): Promise<T> {
  const token = draft ? process.env.DIGGAMA_PREVIEW_TOKEN : process.env.DIGGAMA_TOKEN;
  if (!token) throw new Error(draft ? 'DIGGAMA_PREVIEW_TOKEN is not set' : 'DIGGAMA_TOKEN is not set');

  const url = new URL(`${API_URL}/resources/${blueprint}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);

  const res = await fetch(url, {
    headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
    // Published content is cached until /api/revalidate expires its tags.
    // Draft Mode skips the cache anyway; no-store makes that explicit.
    ...(draft
      ? { cache: 'no-store' as const }
      : { cache: 'force-cache' as const, next: { tags: [CACHE_TAG, blueprintTag(blueprint)] } }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Diggama ${res.status} on ${url.pathname}${url.search}: ${body.slice(0, 300)}`);
  }
  return (await res.json()) as T;
}

// published_at is a date, and a future date means "scheduled".
// Only records whose date has passed belong on the live site.
const isLive = (record: DiggamaResource<unknown>) =>
  record.published_at !== null && new Date(record.published_at) <= new Date();

// Every page of a list, 100 records at a time (the API maximum).
async function list<A>(blueprint: Blueprint, params: Record<string, string>, options: ReadOptions) {
  const records: DiggamaResource<A>[] = [];
  for (let page = 1; ; page++) {
    const query: Record<string, string> = { ...params, per_page: '100', page: String(page) };
    if (!options.draft) query['filter[published]'] = 'true';

    const json = await get<ListResponse<A>>(blueprint, query, options);
    records.push(...json.data);
    if (page >= json.meta.last_page) break;
  }
  return options.draft ? records : records.filter(isLive);
}

// A singular blueprint holds exactly one record, served as a one-item list.
// It has no draft state, so it is read without the published filter.
async function single<A>(blueprint: Blueprint, options: ReadOptions) {
  const json = await get<ListResponse<A>>(blueprint, { per_page: '1' }, options);
  const record = json.data[0];
  if (!record) throw new Error(`Diggama: "${blueprint}" has no record yet`);
  return record;
}

export function getHomepage(options: ReadOptions = {}) {
  return single<Homepage>('homepage', options);
}

export function getPosts(options: ReadOptions = {}) {
  return list<Post>('posts', { sort: '-published_at' }, options);
}

export async function getPost(slug: string, options: ReadOptions = {}) {
  const posts = await list<Post>('posts', { 'filter[slug][eq]': slug }, options);
  return posts[0] ?? null;
}

export function getTeamMembers(options: ReadOptions = {}) {
  return list<TeamMember>('team-members', { sort: 'name' }, options);
}

// Writes one record to the read-only contact-submissions blueprint with the
// create-only token. Never cached. Returns Diggama's field errors on 422.
export async function createContactSubmission(
  attributes: ContactSubmission,
): Promise<{ ok: true } | { ok: false; fieldErrors: Partial<Record<keyof ContactSubmission, string>> }> {
  const token = process.env.DIGGAMA_FORM_TOKEN;
  if (!token) throw new Error('DIGGAMA_FORM_TOKEN is not set');

  const res = await fetch(`${API_URL}/resources/contact-submissions`, {
    method: 'POST',
    cache: 'no-store',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ attributes }),
  });

  if (res.status === 201) return { ok: true };

  if (res.status === 422) {
    const json = (await res.json()) as { error: { details: Record<string, string[]> } };
    const fieldErrors: Partial<Record<keyof ContactSubmission, string>> = {};
    for (const [key, messages] of Object.entries(json.error.details ?? {})) {
      const field = key.replace(/^attributes\./, '') as keyof ContactSubmission;
      fieldErrors[field] = messages[0];
    }
    return { ok: false, fieldErrors };
  }

  throw new Error(`Diggama ${res.status} on POST contact-submissions: ${(await res.text()).slice(0, 300)}`);
}

What each part does, and why:

  • cache: 'force-cache' with two tags. In Next.js 16, fetch caching is opt-in. According to the fetch reference, without a cache option a request is fetched once during next build for a static route, and on every request otherwise. Opting in and tagging every response with diggama and diggama:<blueprint> lets one route handler expire everything, or a single blueprint.
  • No Cache Components. The project was created with --no-cache-components. Next.js 16 also offers 'use cache' with cacheTag, but tagged fetch calls do the same job here with less configuration, and they are what we tested on Workers.
  • filter[published]=true and the isLive check. A view token already sees only published records on a standard blueprint (publishing). The explicit filter keeps production correct even if someone swaps in a broader token. It compares published_at to the current time, to the second, so a post scheduled for 18:00 stays out of a build that runs at 17:59. isLive repeats the same check in code, which keeps the rule true against the local mock server described in the launch checklist.
  • The pagination loop. Lists return 25 records by default and at most 100 per request (pagination). Without the loop, the blog silently stops at 25 posts.
  • getPost filters by slug rather than downloading every post and searching. That keeps each cached response small. In next dev, Next.js refuses to cache a single fetch response over 2 MB and throws an error, which a list of 100 long posts can reach.
  • Draft Mode gets a different token and no cache. The fetch reference notes that Draft Mode bypasses the cache entirely; no-store makes that visible in the code.
  • import 'server-only' makes the build fail if a Client Component ever imports this file, which would otherwise put a token on the path to the browser.

Step 4: How do you render rich text and the shared layout?

Render Diggama rich text with a small Server Component that inserts the HTML, and style it with a scoped .rich-text class. Diggama sanitises rich text on the server against an allowlist, so scripts and event handlers are already stripped when the API returns it (blueprints).

Prompt for your AI agent
Create components/RichText.tsx: a component that takes an HTML string or null, returns null when empty, and renders the HTML in a div with the class rich-text plus an optional className. Then replace app/globals.css: keep the Tailwind import and theme tokens, add a muted and an accent color, use a system font stack, and add .rich-text styles for headings, links, lists, blockquotes, images and iframes (Diggama rich text can contain YouTube and Vimeo embeds).
components/RichText.tsx
// Renders a Diggama rich text field. Diggama sanitises rich text on the
// server against an allowlist (no scripts, no event handlers), so the HTML
// is inserted as is.
export function RichText({ html, className = '' }: { html: string | null; className?: string }) {
  if (!html) return null;
  return <div className={`rich-text ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
}
app/globals.css
@import "tailwindcss";

:root {
  --background: #ffffff;
  --foreground: #171717;
  --muted: #5f6368;
  --accent: #1d4ed8;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-muted: var(--muted);
  --color-accent: var(--accent);
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

/* HTML from Diggama rich text fields */
.rich-text > * + * { margin-top: 1em; }
.rich-text h2 { font-size: 1.5rem; font-weight: 700; margin-top: 1.5em; }
.rich-text h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.25em; }
.rich-text a { color: var(--accent); text-decoration: underline; }
.rich-text ul { list-style: disc; padding-left: 1.5em; }
.rich-text ol { list-style: decimal; padding-left: 1.5em; }
.rich-text blockquote { border-left: 3px solid var(--muted); padding-left: 1em; color: var(--muted); }
.rich-text img { max-width: 100%; height: auto; border-radius: 0.5rem; }
.rich-text iframe { width: 100%; aspect-ratio: 16 / 9; }

The layout holds the only hardcoded strings allowed by AGENTS.md: the navigation and the footer. It also shows a banner when Draft Mode is on, so an editor never mistakes a preview for the live site.

Prompt for your AI agent
Replace app/layout.tsx: remove the Geist fonts, set metadata with a default title "Northwind Studio" and a "%s | Northwind Studio" template, add a header with links to /, /blog, /team and /contact, a main container and a footer. Read draftMode() and, when it is enabled, show a banner with a plain <a> link to /api/draft/exit (not next/link, so it is never prefetched).
app/layout.tsx
import type { Metadata } from 'next';
import Link from 'next/link';
import { draftMode } from 'next/headers';
import './globals.css';

export const metadata: Metadata = {
  title: { default: 'Northwind Studio', template: '%s | Northwind Studio' },
};

export default async function RootLayout({ children }: LayoutProps<'/'>) {
  const { isEnabled: draft } = await draftMode();

  return (
    <html lang="en">
      <body className="min-h-screen flex flex-col antialiased">
        {draft && (
          <div className="bg-amber-100 px-4 py-2 text-center text-sm">
            Preview: you are seeing drafts.{' '}
            <a href="/api/draft/exit" className="underline">
              Exit preview
            </a>
          </div>
        )}
        <header className="border-b">
          <nav className="mx-auto flex max-w-5xl items-center gap-6 px-4 py-4">
            <Link href="/" className="mr-auto font-bold">
              Northwind Studio
            </Link>
            <Link href="/blog">Blog</Link>
            <Link href="/team">Team</Link>
            <Link href="/contact">Contact</Link>
          </nav>
        </header>
        <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-12">{children}</main>
        <footer className="border-t px-4 py-6 text-center text-sm text-muted">
          © Northwind Studio
        </footer>
      </body>
    </html>
  );
}

Reading draftMode() in the layout does not make pages dynamic. The build output still marks every page as static (○ or ●), and Next.js renders on request only when the Draft Mode cookie is present.

Step 5: How do you build the homepage from a singular blueprint?

Read the singular homepage record with getHomepage() and render its fields directly, with no fallback text. A singular blueprint has exactly one record, always served by the API, with no draft state, so an editor’s Save Changes goes live with the next revalidation (blueprints).

Prompt for your AI agent
Replace app/page.tsx. Fetch getHomepage({ draft }) and getPosts({ draft }) in parallel, with draft from draftMode(). Render hero-title as the h1, hero-subtitle under it, hero-image with next/image (fill, priority, sizes) inside an aspect-ratio container, intro with RichText, then the three latest posts as links to /blog/[slug]. Add generateMetadata using hero-title as the absolute title and hero-subtitle as the description. No fallback strings: if a field is empty, render nothing for it. Then run npm run build.
app/page.tsx
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { draftMode } from 'next/headers';
import { getHomepage, getPosts } from '@/lib/diggama';
import { RichText } from '@/components/RichText';

export async function generateMetadata(): Promise<Metadata> {
  const home = await getHomepage();
  const { 'hero-title': title, 'hero-subtitle': subtitle } = home.attributes;
  return {
    ...(title && { title: { absolute: title } }),
    ...(subtitle && { description: subtitle }),
  };
}

export default async function HomePage() {
  const { isEnabled: draft } = await draftMode();
  const [home, posts] = await Promise.all([getHomepage({ draft }), getPosts({ draft })]);
  const { 'hero-title': title, 'hero-subtitle': subtitle, 'hero-image': image, intro } = home.attributes;

  return (
    <>
      <section className="grid items-center gap-8 md:grid-cols-2">
        <div>
          <h1 className="text-4xl font-bold tracking-tight md:text-5xl">{title}</h1>
          {subtitle && <p className="mt-4 text-lg text-muted">{subtitle}</p>}
        </div>
        {image && (
          <div className="relative aspect-[4/3] overflow-hidden rounded-xl">
            <Image src={image} alt="" fill priority sizes="(min-width: 768px) 50vw, 100vw" className="object-cover" />
          </div>
        )}
      </section>

      <RichText html={intro} className="mt-12 max-w-2xl" />

      {posts.length > 0 && (
        <section className="mt-16">
          <h2 className="text-2xl font-bold">Latest posts</h2>
          <ul className="mt-6 space-y-4">
            {posts.slice(0, 3).map((post) => (
              <li key={post.id}>
                <Link href={`/blog/${post.attributes.slug}`} className="font-medium underline">
                  {post.attributes.title}
                </Link>
                {post.attributes.excerpt && <p className="text-muted">{post.attributes.excerpt}</p>}
              </li>
            ))}
          </ul>
        </section>
      )}
    </>
  );
}

Two details matter here. generateMetadata and the page both call getHomepage(), but Next.js memoizes identical fetch requests across generateMetadata and the page, so Diggama receives one request. And the homepage reads posts too, which gives the page both the diggama:homepage and the diggama:posts tags: publishing a post refreshes the “Latest posts” list without any extra code.

The image container has a fixed aspect ratio because Diggama returns an image as a URL without dimensions. fill with a sized parent avoids layout shift without knowing the file’s width and height.

Step 6: How do you build the blog with generateStaticParams and generateMetadata?

Build two routes: app/blog/page.tsx lists getPosts(), and app/blog/[slug]/page.tsx prerenders one page per published post with generateStaticParams, sets its metadata with generateMetadata, and calls notFound() for unknown slugs.

Prompt for your AI agent
Create lib/format.ts with a formatDate(iso) helper (en-GB, long date, Europe/Paris time zone, "Draft" when null). Create app/blog/page.tsx listing getPosts({ draft }) with cover, date, title and excerpt, each linking to /blog/[slug]. Create app/blog/[slug]/page.tsx with generateStaticParams from getPosts(), generateMetadata (title, excerpt as description, Open Graph article with publishedTime and cover), and a page that renders date, title, cover and content with RichText, calling notFound() when getPost returns null. Use the PageProps<'/blog/[slug]'> type and await params. Run npm run build and check that the output lists one SSG route per published post.
lib/format.ts
// Diggama dates are ISO 8601 strings with an offset, in Paris time
// (2026-07-01T11:00:00+02:00). Format them in one fixed time zone so the
// date matches what editors see; change it to your audience's zone.
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'Europe/Paris' });

export function formatDate(iso: string | null) {
  return iso ? dateFormat.format(new Date(iso)) : 'Draft';
}
app/blog/page.tsx
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { draftMode } from 'next/headers';
import { getPosts } from '@/lib/diggama';
import { formatDate } from '@/lib/format';

export const metadata: Metadata = { title: 'Blog' };

export default async function BlogIndex() {
  const { isEnabled: draft } = await draftMode();
  const posts = await getPosts({ draft });

  return (
    <>
      <h1 className="text-4xl font-bold">Blog</h1>
      <ul className="mt-10 grid gap-10 md:grid-cols-2">
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/blog/${post.attributes.slug}`} className="group block">
              {post.attributes.cover && (
                <div className="relative mb-4 aspect-[16/9] overflow-hidden rounded-lg">
                  <Image src={post.attributes.cover} alt="" fill sizes="(min-width: 768px) 50vw, 100vw" className="object-cover" />
                </div>
              )}
              <time dateTime={post.published_at ?? undefined} className="text-sm text-muted">
                {formatDate(post.published_at)}
              </time>
              <h2 className="mt-1 text-xl font-semibold group-hover:underline">{post.attributes.title}</h2>
              {post.attributes.excerpt && <p className="mt-2 text-muted">{post.attributes.excerpt}</p>}
            </Link>
          </li>
        ))}
      </ul>
    </>
  );
}
app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { draftMode } from 'next/headers';
import { getPost, getPosts } from '@/lib/diggama';
import { formatDate } from '@/lib/format';
import { RichText } from '@/components/RichText';

// One static page per published post at build time. A post published later
// is rendered on its first request, then cached like the others.
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({ slug: post.attributes.slug }));
}

export async function generateMetadata({ params }: PageProps<'/blog/[slug]'>): Promise<Metadata> {
  const { slug } = await params;
  const { isEnabled: draft } = await draftMode();
  const post = await getPost(slug, { draft });
  if (!post) return {};

  const { title, excerpt, cover } = post.attributes;
  return {
    title,
    description: excerpt ?? undefined,
    openGraph: {
      type: 'article',
      title,
      description: excerpt ?? undefined,
      publishedTime: post.published_at ?? undefined,
      images: cover ? [cover] : undefined,
    },
  };
}

export default async function PostPage({ params }: PageProps<'/blog/[slug]'>) {
  const { slug } = await params;
  const { isEnabled: draft } = await draftMode();
  const post = await getPost(slug, { draft });
  if (!post) notFound();

  const { title, cover, content } = post.attributes;

  return (
    <article className="mx-auto max-w-2xl">
      <time dateTime={post.published_at ?? undefined} className="text-sm text-muted">
        {formatDate(post.published_at)}
      </time>
      <h1 className="mt-2 text-4xl font-bold tracking-tight">{title}</h1>
      {cover && (
        <div className="relative my-8 aspect-[16/9] overflow-hidden rounded-xl">
          <Image src={cover} alt="" fill priority sizes="(min-width: 768px) 672px, 100vw" className="object-cover" />
        </div>
      )}
      <RichText html={content} className="text-lg leading-relaxed" />
    </article>
  );
}

The build output confirms what was generated. Against our test data (three published posts, one draft, one scheduled), it listed exactly the three published slugs:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/draft
├ ƒ /api/draft/exit
├ ƒ /api/revalidate
├ ○ /blog
├   /blog/[slug]
│ ├ ● /blog/new-office
│ ├ ● /blog/choosing-a-typeface
│ └ ● /blog/design-sprint
├ ○ /contact
└ ○ /team

What happens to a post published after the deploy? dynamicParams defaults to true, so a slug missing from generateStaticParams is rendered on its first request and cached. Before publication, that slug returns a 404, and the 404 is cached too, with the diggama:posts tag. When the editor publishes, the webhook expires that tag and the next visit renders the post. We checked this sequence on the local Worker: 404 before publishing, still 404 after publishing without a webhook, 200 right after the webhook.

In Next.js 16, params is a Promise, hence await params. PageProps<'/blog/[slug]'> is a global type that Next.js generates from your routes, so a renamed folder becomes a type error instead of a runtime bug.

Step 7: How do you build the team page?

The team page is a list of team-members records sorted by name, with a photo, a role and a rich text bio. It reuses everything from the previous steps.

Prompt for your AI agent
Create app/team/page.tsx: list getTeamMembers({ draft }) in a responsive grid with photo (next/image fill in a square container, alt = name), name, role and bio rendered with RichText. Metadata title "Team". Then search app/, components/ and lib/ for user-facing strings that are not navigation labels, form labels or messages, or the legal footer, and list them with file and line.
app/team/page.tsx
import type { Metadata } from 'next';
import Image from 'next/image';
import { draftMode } from 'next/headers';
import { getTeamMembers } from '@/lib/diggama';
import { RichText } from '@/components/RichText';

export const metadata: Metadata = { title: 'Team' };

export default async function TeamPage() {
  const { isEnabled: draft } = await draftMode();
  const members = await getTeamMembers({ draft });

  return (
    <>
      <h1 className="text-4xl font-bold">Team</h1>
      <ul className="mt-10 grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
        {members.map((member) => (
          <li key={member.id}>
            {member.attributes.photo && (
              <div className="relative mb-4 aspect-square overflow-hidden rounded-lg">
                <Image
                  src={member.attributes.photo}
                  alt={member.attributes.name}
                  fill
                  sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
                  className="object-cover"
                />
              </div>
            )}
            <h2 className="text-lg font-semibold">{member.attributes.name}</h2>
            {member.attributes.role && <p className="text-muted">{member.attributes.role}</p>}
            <RichText html={member.attributes.bio} className="mt-3 text-sm" />
          </li>
        ))}
      </ul>
    </>
  );
}

The second half of that prompt is the habit that keeps the site editable. Run it after every feature, and move anything it finds into a blueprint field.

Step 8: How do you send the contact form to a read-only blueprint with a Server Action?

Use a Server Action that validates the input, then POSTs it to /v2/resources/contact-submissions with the create-only token. The action runs on the Worker, so the token never reaches the browser, and the form works even with JavaScript disabled.

Prompt for your AI agent
Create the contact form:
- app/contact/actions.ts: a 'use server' action sendContact(prevState, formData) for useActionState. Reject silently when the hidden honeypot field "company" is filled. Validate name (required, under 200 characters), email (format) and message (required, under 5000 characters). Call createContactSubmission from lib/diggama.ts. Return field errors from our validation or from Diggama's 422, and return the submitted values so the form keeps them on error.
- app/contact/ContactForm.tsx: a client component using useActionState, showing field errors, a pending state on the button and a success message.
- app/contact/page.tsx: a static page rendering the form.
Run npm run build and confirm /contact is still static.
app/contact/actions.ts
'use server';

import { createContactSubmission, type ContactSubmission } from '@/lib/diggama';

export type ContactState = {
  status: 'idle' | 'success' | 'error';
  message?: string;
  values?: ContactSubmission;
  fieldErrors?: Partial<Record<keyof ContactSubmission, string>>;
};

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
  // Honeypot: real visitors never see or fill this field.
  if (formData.get('company')) return { status: 'success' };

  const input = {
    name: String(formData.get('name') ?? '').trim(),
    email: String(formData.get('email') ?? '').trim(),
    message: String(formData.get('message') ?? '').trim(),
  };

  const fieldErrors: ContactState['fieldErrors'] = {};
  if (!input.name) fieldErrors.name = 'Please enter your name.';
  if (input.name.length > 200) fieldErrors.name = 'Please keep your name under 200 characters.';
  if (!EMAIL.test(input.email)) fieldErrors.email = 'Please enter a valid email address.';
  if (!input.message) fieldErrors.message = 'Please write a message.';
  if (input.message.length > 5000) fieldErrors.message = 'Please keep your message under 5000 characters.';
  if (Object.keys(fieldErrors).length > 0) return { status: 'error', values: input, fieldErrors };

  try {
    const result = await createContactSubmission(input);
    if (!result.ok) return { status: 'error', values: input, fieldErrors: result.fieldErrors };
    return { status: 'success' };
  } catch (error) {
    console.error(error);
    return { status: 'error', values: input, message: 'Something went wrong. Please try again later.' };
  }
}
app/contact/ContactForm.tsx
'use client';

import { useActionState } from 'react';
import { sendContact, type ContactState } from './actions';

const initialState: ContactState = { status: 'idle' };

export function ContactForm() {
  const [state, formAction, pending] = useActionState(sendContact, initialState);

  if (state.status === 'success') {
    return <p className="rounded-lg bg-green-50 p-4">Thanks, your message was sent. We will reply soon.</p>;
  }

  return (
    <form action={formAction} className="max-w-xl space-y-5">
      <Field label="Name" name="name" value={state.values?.name} error={state.fieldErrors?.name} />
      <Field label="Email" name="email" type="email" value={state.values?.email} error={state.fieldErrors?.email} />
      <Field label="Message" name="message" textarea value={state.values?.message} error={state.fieldErrors?.message} />

      {/* Honeypot: hidden from people and screen readers, filled by bots */}
      <input type="text" name="company" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hidden" />

      {state.message && <p className="text-red-700">{state.message}</p>}

      <button
        type="submit"
        disabled={pending}
        className="rounded-lg bg-accent px-5 py-2.5 font-medium text-white disabled:opacity-60"
      >
        {pending ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}

function Field({
  label,
  name,
  type = 'text',
  textarea = false,
  value,
  error,
}: {
  label: string;
  name: string;
  type?: string;
  textarea?: boolean;
  value?: string;
  error?: string;
}) {
  const className = 'mt-1 block w-full rounded-lg border px-3 py-2';
  return (
    <label className="block">
      <span className="font-medium">{label}</span>
      {textarea ? (
        <textarea name={name} rows={6} required defaultValue={value} className={className} aria-invalid={!!error} />
      ) : (
        <input name={name} type={type} required defaultValue={value} className={className} aria-invalid={!!error} />
      )}
      {error && <span className="mt-1 block text-sm text-red-700">{error}</span>}
    </label>
  );
}
app/contact/page.tsx
import type { Metadata } from 'next';
import { ContactForm } from './ContactForm';

export const metadata: Metadata = { title: 'Contact' };

export default function ContactPage() {
  return (
    <>
      <h1 className="mb-8 text-4xl font-bold">Contact</h1>
      <ContactForm />
    </>
  );
}

Why validate twice? Diggama validates field types and required fields and answers 422 VALIDATION_ERROR with messages keyed by field, such as attributes.email (errors). Validating in the action first gives you your own wording, a length limit Diggama does not enforce on a Text field, and keeps junk out of the project. createContactSubmission still maps Diggama’s 422 to the same field names, in case the blueprint gains a rule later.

On the local Worker, a form POST without JavaScript returned the field error for an invalid email with the typed values preserved, and a valid submission created the record (201 Created, as documented in create resources).

To be notified of new messages, add a Diggama Workflow on Configuration › Workflows: Event trigger, conditions Event type = Resource Created and Resource type = contact-submissions, action Send email. The body can include {{name}}, {{email}} and {{message}}.

Step 9: How do you revalidate pages when content changes in Diggama?

Add a route handler at /api/revalidate that checks a secret from the query string and calls revalidateTag, then point a Diggama Workflow’s Send webhook action at it. Editors click Publish, and the next request for any affected page is rendered with fresh content, without a build.

The constraint that shapes this route: Diggama’s webhook is a ping. According to Diggama’s Workflows documentation, it sends a POST with Content-Type: application/json and the body {"event": "resource_updated"} (or resource_created, resource_deleted), with no record, no blueprint and no signature header. The route cannot tell which page changed, so it expires a tag broad enough to cover it, and authenticates the caller with a secret in the URL.

sequenceDiagram
    actor E as Editor
    participant D as Diggama
    participant W as Next.js Worker
    actor V as Next visitor
    E->>D: Publish a post
    D->>W: Workflow webhook to /api/revalidate
    W->>W: revalidateTag for the posts tag
    W-->>D: 200, run marked Success
    V->>W: GET /blog
    W->>D: Fetch posts with the site token
    D-->>W: Published posts
    W-->>V: Fresh page, cached again
Prompt for your AI agent
Create lib/secret.ts with matchesSecret(given, expected) using crypto.timingSafeEqual, returning false when the expected value is missing. Create app/api/revalidate/route.ts with only a POST handler: reject with 401 unless the secret query parameter matches REVALIDATE_SECRET; read the optional JSON body {event}; if a blueprint query parameter names one of BLUEPRINTS from lib/diggama.ts, expire that blueprint's tag, otherwise expire CACHE_TAG; use revalidateTag(tag, { expire: 0 }); return JSON with the tag and event.
lib/secret.ts
import 'server-only';
import { timingSafeEqual } from 'node:crypto';

// Compares a secret from a URL with the expected value in constant time.
// Returns false when the expected value is not configured.
export function matchesSecret(given: string | null, expected: string | undefined) {
  if (!expected || !given) return false;
  const a = Buffer.from(given);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import type { NextRequest } from 'next/server';
import { BLUEPRINTS, CACHE_TAG, blueprintTag, type Blueprint } from '@/lib/diggama';
import { matchesSecret } from '@/lib/secret';

// Called by a Diggama Workflow ("Send webhook") on resource created, updated
// or deleted. Diggama POSTs {"event": "resource_updated"} and nothing else:
// no record, no blueprint, no signature. The secret travels in the URL.
//
//   /api/revalidate?secret=XXX                  expires all Diggama content
//   /api/revalidate?secret=XXX&blueprint=posts  expires one blueprint

export async function POST(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  if (!matchesSecret(searchParams.get('secret'), process.env.REVALIDATE_SECRET)) {
    return Response.json({ revalidated: false, message: 'Invalid secret' }, { status: 401 });
  }

  const body = (await request.json().catch(() => ({}))) as { event?: string };
  const blueprint = searchParams.get('blueprint') as Blueprint | null;
  const tag = blueprint && BLUEPRINTS.includes(blueprint) ? blueprintTag(blueprint) : CACHE_TAG;

  // expire: 0 drops the cached data now, so the next visitor (often the
  // editor who just clicked Publish) gets a fresh page, not a stale one.
  revalidateTag(tag, { expire: 0 });

  return Response.json({ revalidated: true, tag, event: body.event ?? null, now: Date.now() });
}

Why { expire: 0 } and not 'max'

In Next.js 16, revalidateTag takes a second argument. The revalidateTag reference recommends 'max', which serves the stale page once while it regenerates in the background, and documents { expire: 0 } for webhooks: the next request blocks until fresh data is fetched. On a company site the next visitor is usually the editor checking their change, and a stale page looks like a failed publish. The cost is one slower request per page after each publish. The single-argument form revalidateTag(tag) is deprecated.

Create the Workflows in Diggama

Open Configuration › Workflows and create one Workflow per content blueprint:

WorkflowTriggerConditionAction: Send webhook, Destination URL
Revalidate homepageEventResource type = homepagehttps://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=homepage
Revalidate postsEventResource type = postshttps://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=posts
Revalidate teamEventResource type = team-membershttps://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=team-members

Without an Event type condition, each Workflow fires on create, update and delete. The Resource type condition keeps contact form submissions from triggering revalidations. If you prefer a single Workflow, drop the condition and the blueprint parameter: the route then expires everything on every event, including each form submission, which is harmless but wasteful.

Behaviors worth knowing, all from Diggama’s Workflows and editing documentation:

  • Publishing, unpublishing and an explicit save of a draft all count as Resource Updated. Background autosaves do not fire workflows. A draft save expires the cache for nothing, which costs one regeneration per page.
  • When a scheduled post reaches its date, a Resource Updated event fires within about five minutes, so scheduled posts appear on their own.
  • The webhook times out after 10 seconds and is tried twice. Any non-2xx answer marks the run as failed in the Workflow’s history, so a wrong secret (our route answers 401) shows up there.

Test the route by hand before wiring Diggama:

curl -X POST "https://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=posts" \
  -H "Content-Type: application/json" \
  -d '{"event":"resource_updated"}'

On the local Worker, changing the homepage title in the API, then calling the route with blueprint=team-members left the page cached (x-nextjs-cache: HIT, old title). Calling it with blueprint=homepage returned MISS and the new title on the next request.

Step 10: How do you deploy Next.js to Cloudflare Workers with OpenNext?

Add the OpenNext configuration, a wrangler.jsonc with an R2 bucket, a D1 database and a Durable Object, then connect the repository to Workers Builds. The three bindings are what on-demand revalidation needs on Workers: R2 stores cached pages and fetch responses, D1 stores when each tag was revalidated, and the Durable Object queue regenerates pages.

Prompt for your AI agent
Set up deployment to Cloudflare Workers with @opennextjs/cloudflare, following https://opennext.js.org/cloudflare/caching for a small site with on-demand revalidation:
- open-next.config.ts with the R2 incremental cache, the Durable Object queue and the D1 tag cache
- wrangler.jsonc for a Worker named northwind-studio with nodejs_compat and global_fetch_strictly_public, the .open-next assets, the WORKER_SELF_REFERENCE service binding, an R2 bucket northwind-studio-cache, a D1 database northwind-studio-tags and the DOQueueHandler Durable Object with its migration
- package.json scripts preview, deploy and cf-typegen
- public/_headers with immutable caching for /_next/static/*
- .dev.vars with NEXTJS_ENV=development, and .open-next, .wrangler and .dev.vars in .gitignore
Then run npm run preview and fetch every route with curl. Do not deploy.
open-next.config.ts
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import d1NextTagCache from '@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache';

// R2 stores cached pages and fetch responses, D1 records when each tag was
// revalidated, and a Durable Object queue regenerates stale pages.
export default defineCloudflareConfig({
  incrementalCache: r2IncrementalCache,
  queue: doQueue,
  tagCache: d1NextTagCache,
});
wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "northwind-studio",
  "main": ".open-next/worker.js",
  "compatibility_date": "2026-10-01",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  },
  "services": [
    {
      "binding": "WORKER_SELF_REFERENCE",
      "service": "northwind-studio"
    }
  ],
  "r2_buckets": [
    {
      "binding": "NEXT_INC_CACHE_R2_BUCKET",
      "bucket_name": "northwind-studio-cache"
    }
  ],
  "d1_databases": [
    {
      "binding": "NEXT_TAG_CACHE_D1",
      "database_name": "northwind-studio-tags",
      "database_id": "REPLACE_WITH_THE_ID_FROM_WRANGLER_D1_CREATE"
    }
  ],
  "durable_objects": {
    "bindings": [
      {
        "name": "NEXT_CACHE_DO_QUEUE",
        "class_name": "DOQueueHandler"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["DOQueueHandler"]
    }
  ],
  "observability": {
    "enabled": true
  }
}
package.json
{
  "name": "northwind-studio",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
    "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
    "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
  },
  "dependencies": {
    "@opennextjs/cloudflare": "^1.20.9",
    "next": "16.3.8",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@tailwindcss/turbopack": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "typescript": "^5",
    "wrangler": "^4.148.0"
  }
}
public/_headers
/_next/static/*
  Cache-Control: public,max-age=31536000,immutable
.dev.vars
NEXTJS_ENV=development

Add the build folders to .gitignore:

cat >> .gitignore <<'EOF'

# OpenNext / Cloudflare
/.open-next/
/.wrangler/
.dev.vars*
EOF

Create the R2 bucket and the D1 database

npx wrangler login
npx wrangler r2 bucket create northwind-studio-cache
npx wrangler d1 create northwind-studio-tags

Copy the database_id printed by the last command into wrangler.jsonc. You do not create the D1 table yourself: opennextjs-cloudflare deploy (and preview, locally) runs CREATE TABLE IF NOT EXISTS revalidations before uploading, and fills the R2 cache with the pages prerendered at build time. Those are the lines Successfully populated cache and Successfully created D1 table in the log.

Test the Worker locally

npm run preview

This builds with OpenNext and serves the Worker on http://localhost:8787 with local R2, D1 and Durable Object simulations. It is the step that caught the Next.js 16.4.0 problem: next build passed, and every page of the Worker then failed. During next build you will also see a warning that the DOQueueHandler class is not exported; it comes from initOpenNextCloudflareForDev() reading wrangler.jsonc and is harmless. In our build, wrangler deploy --dry-run reported a Worker of 1,149 KiB gzipped, well under the 3 MiB Free plan limit.

Connect the repository to Workers Builds

Push the repository, then in the Cloudflare dashboard open Workers & Pages, create a Worker by importing the repository, and set:

SettingValue
Worker namenorthwind-studio (must match name in wrangler.jsonc, see troubleshooting builds)
Build commandnpx opennextjs-cloudflare build
Deploy commandnpx opennextjs-cloudflare deploy
Build variablesDIGGAMA_TOKEN

The build needs DIGGAMA_TOKEN to prerender pages; OpenNext’s environment variables guide says build-time values go in Workers Builds’ Build variables and secrets. After the first deploy, add the runtime secrets under the Worker’s Settings > Variables & Secrets (Cloudflare’s build configuration docs state that build variables are not accessible at runtime), or from your terminal:

npx wrangler secret put DIGGAMA_TOKEN
npx wrangler secret put DIGGAMA_FORM_TOKEN
npx wrangler secret put DIGGAMA_PREVIEW_TOKEN
npx wrangler secret put REVALIDATE_SECRET
npx wrangler secret put DRAFT_SECRET

DIGGAMA_TOKEN appears in both places on purpose. The build uses it to prerender, and the Worker uses it to regenerate pages after each revalidation.

Do not deploy from your laptop with a test .env.local. OpenNext copies the values of your .env files into the Worker bundle (we found them in .open-next/cloudflare/next-env.mjs after a build) and uses them for any variable the Worker’s own secrets do not set. A local DIGGAMA_API_URL pointing to a mock would then ship to production. Workers Builds clones the repository, where .env* files are ignored, so deploying through it avoids the problem. That is also why AGENTS.md tells your agent never to run npm run deploy.

Step 11: How do you preview drafts with Next.js Draft Mode?

Add a route handler that checks a secret, enables Draft Mode and redirects to the page to preview. Every page already passes draft from draftMode() to the Diggama client, which then uses the preview token and skips the cache.

Prompt for your AI agent
Create app/api/draft/route.ts: a GET handler that checks the secret query parameter against DRAFT_SECRET with matchesSecret, accepts a path parameter that must be a same-site path (starts with one slash, not // or /\), enables draftMode() and redirects to it. Create app/api/draft/exit/route.ts that disables Draft Mode and redirects to /. Then test: a draft post must return 404 normally and 200 with the preview banner after visiting /api/draft.
app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { matchesSecret } from '@/lib/secret';

// Turns on Draft Mode, then redirects to the page to preview:
//   /api/draft?secret=XXX&path=/blog/my-draft-post
// In Draft Mode, pages read Diggama with the preview token and skip the cache.

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  if (!matchesSecret(searchParams.get('secret'), process.env.DRAFT_SECRET)) {
    return new Response('Invalid secret', { status: 401 });
  }

  // Same-site paths only ("/x", not "//x" or "/\x"), so this is not an open redirect.
  const path = searchParams.get('path') ?? '/';
  const safePath = /^\/(?![/\\])/.test(path) ? path : '/';

  (await draftMode()).enable();
  redirect(safePath);
}
app/api/draft/exit/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET() {
  (await draftMode()).disable();
  redirect('/');
}

Diggama has no “preview URL” button for external sites, so give editors a bookmark of the form https://YOUR_SITE/api/draft?secret=DRAFT_SECRET&path=/blog/the-post-slug. Anyone with that link sees unpublished content, so treat DRAFT_SECRET like a password, rotate it when someone leaves, or put /api/draft behind Cloudflare Access.

On the local Worker, /blog/draft-post returned 404, then 200 with the preview banner and the draft’s title after /api/draft. The blog index in Draft Mode listed the draft and the scheduled post. After /api/draft/exit, the draft returned 404 again. A path like //evil.com was redirected to /.

How do you check the whole site before launch?

Check the six behaviors that break silently: drafts leaking, the blog stopping at 25 posts, hardcoded copy, the form, revalidation and the preview. Each takes a minute with npm run preview or on the deployed Worker.

  • npm run build lists one SSG route per published post, and no draft or scheduled slug
  • With more than 100 posts (or a lower per_page during a test), the blog index shows all of them
  • The hardcoded-string audit prompt from step 7 comes back clean
  • A test submission appears in contact-submissions and triggers the notification email
  • Editing the homepage title in Diggama changes the live page on the next refresh, and the Workflow history shows Success
  • A new post published after the deploy returns 200 on its URL right after publishing
  • /api/draft shows a draft with the banner; /api/draft/exit hides it again
  • No token appears in the browser: search the page source and the JavaScript chunks for your token prefix
  • Run the website grader on the live URL for titles, descriptions and image attributes

A testing tip: you can build and run the whole site without a Diggama token by pointing DIGGAMA_API_URL at a small local server that returns the documented response shapes (a data, links and meta list, a 201 on create). That is how we tested this tutorial. Remove the variable before deploying.

What are the limitations of Next.js on Cloudflare Workers?

Most App Router features work on Workers through OpenNext, but a few do not, and some cost extra. Check this list before you ask your agent for a feature that depends on one of them.

TopicStatus with @opennextjs/cloudflare 1.20.9Source
App Router, route handlers, Server Actions, SSG, ISR, revalidateTagSupportedOpenNext
Edge runtime (export const runtime = 'edge')Not supported; remove itOpenNext get started
Node.js middleware (added in Next.js 15.2)Not yet supportedOpenNext
proxy.ts (Next.js 16’s new name for middleware, Node.js runtime by default)Not documented by OpenNext; this tutorial does not use it. Test with npm run preview before adding oneNext.js proxy
Worker size3 MiB on Workers Free, 10 MiB on Workers PaidOpenNext
Image optimizationThrough the Cloudflare Images binding, billed separatelyOpenNext images
Next.js 16.4.0Pages fail at runtime (tested 7 October 2026)Our test, see above

The storage behind revalidation has free allowances: R2 includes 10 GB-month of storage, 1 million Class A (write) and 10 million Class B (read) operations per month, and SQLite-backed Durable Objects, the kind the queue uses, are available on the Workers Free plan. A company site stays far below those numbers.

If you would rather not run R2, D1 and a Durable Object, there is a simpler path: use OpenNext’s static assets cache (no revalidation support), make every page static, and have the Diggama Workflow call a Workers Builds deploy hook instead of /api/revalidate. Each publish then costs a full build, as in the Astro tutorial. The pillar guide explains the deploy hook setup.

What goes wrong most often, and how do you fix it?

Most problems in this stack come from the cache or the environment, not from React code. These are the ones we hit or that the documentation warns about.

SymptomLikely causeFix
Every page returns 500 on the Worker, log says Unexpected loadManifest(...)Next.js version newer than the adapter supportsPin the last version that works with npm run preview (16.3.8 here)
Publish in Diggama, page does not changeFetch not tagged, Workflow failed, or wrong secretCheck the Workflow’s run history; make sure the page reads through lib/diggama.ts
Build fails with Diggama 401Token mistyped, expired or revokedCreate a new token in Configuration › API Tokens (an expiration date cannot be extended) and update the variable
Build fails with Diggama 403Token lacks the ability on that blueprintEdit the token’s abilities in Configuration › API Tokens (the value stays the same)
Build fails with DIGGAMA_TOKEN is not setVariable missing in Workers BuildsAdd it under Build variables and secrets
Pages built fine, but a new post returns 500DIGGAMA_TOKEN missing at runtimenpx wrangler secret put DIGGAMA_TOKEN
Blog shows exactly 25 postsNo pagination loopRequest per_page=100 and loop until meta.last_page
429 RATE_LIMIT_EXCEEDED during a buildOver 1,000 requests per minute for the projectWait error.details.retry_after seconds; reduce per-post requests
next dev throws “items over 2MB can not be cached”One list response is too largeLower per_page for that list
A field renders nothing, no errorWrong field key (heroTitle instead of hero-title)Ask your agent to check keys with the MCP describe_blueprint tool

Where do you go from here?

You now have a Next.js site that reads every word from Diggama, refreshes within a request of a publish, takes contact messages into a read-only blueprint, and previews drafts on the live domain. From here:

FAQ

Frequently Asked Questions

Something else? Get in touch.

Can a Next.js App Router site run on Cloudflare Workers?

Yes. The @opennextjs/cloudflare adapter takes the output of next build and turns it into a Worker, with support for the App Router, route handlers, Server Actions, static generation, ISR and on-demand revalidation. Edge runtime routes and Node.js middleware are not supported, OpenNext does not yet document Next.js 16's proxy.ts, and the compressed Worker must stay under 3 MiB on the Workers Free plan or 10 MiB on the Paid plan.

Why does the revalidation route expire every page instead of only the one that changed?

A Diggama webhook sends only the event name, for example {"event": "resource_updated"}, with no record id or blueprint. The route therefore cannot know which page changed, so by default it expires the tag shared by every Diggama fetch. If you create one Workflow per blueprint and add &blueprint=posts to its URL, the route expires only that blueprint's tag.

Do I need R2, D1 and a Durable Object for a small Next.js site on Cloudflare?

Only if you want on-demand revalidation. OpenNext stores cached pages in R2, the time each tag was revalidated in D1, and uses a Durable Object queue to regenerate pages. A purely static site can use the static assets cache instead and rebuild on every publish through a Workers Builds deploy hook, at the cost of a full build per change.

Should I use vinext instead of OpenNext to deploy Next.js on Cloudflare?

Cloudflare's Next.js guide now recommends vinext, a Vite plugin that reimplements the Next.js API, but its documentation also describes it as beta. OpenNext runs the real next build output and is at version 1.x. The application code in this tutorial is standard App Router code, so trying vinext later mostly means changing the build and deploy setup, after running its compatibility check.

Which AI coding agents can build this Next.js site?

Any agent that edits files, runs npm commands and connects to a remote MCP server with an Authorization header: Claude Code, OpenAI Codex (CLI, IDE extension or desktop app), Cursor, Gemini CLI and GitHub Copilot agent mode all do. The prompts are the same for every agent. Only the file the agent reads its rules from and the MCP setup differ, and Step 2 gives both for each tool.

How do editors preview a draft post before publishing?

They open /api/draft?secret=...&path=/blog/the-slug on the live site. The route turns on Next.js Draft Mode, and every page then reads Diggama with a preview token that also sees drafts and scheduled records, bypassing the cache. A banner with an exit link shows that preview is on.

How many API requests does a build make against Diggama?

In this setup, one request per page of 100 records for each list, plus one request per blog post for its own page, plus one for the homepage. Diggama allows 1,000 requests per minute per project, shared by all its tokens, so a blog with a few hundred posts builds comfortably. Cached responses are reused at runtime until a webhook expires them.

Keep reading

Related guides.

Your site, written by AI. Your content, managed in Diggama.

Model your content once, fetch it from Astro, Next.js or plain HTML, and let your team edit it without touching the code.

No credit card required · 3 months free trial · Cancel anytime