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.
| Route | Content source | Rendering | Updated by |
|---|---|---|---|
/ | homepage (singular) and the latest posts | Static, cached | Webhook to /api/revalidate |
/blog | posts | Static, cached | Webhook to /api/revalidate |
/blog/[slug] | One record of posts | Static per post, new posts rendered on first visit | Webhook to /api/revalidate |
/team | team-members | Static, cached | Webhook to /api/revalidate |
/contact | Form, writes to contact-submissions | Static page, Server Action | Visitors |
/api/revalidate, /api/draft | None | Route handlers | Diggama 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
| Agent | Install | Plan |
|---|---|---|
| Claude Code | npm 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 Codex | npm install -g @openai/codex, then codex and Sign in with ChatGPT (CLI docs) | Included in ChatGPT plans, Plus is $20/month (pricing) |
| Cursor | The editor, or the agent CLI (install) | Pro from $20/month; the free Hobby plan has limited agent requests (pricing) |
| Gemini CLI | npm install -g @google/gemini-cli, then gemini (README) | Free tier with a Google account (quotas) |
| GitHub Copilot | VS Code with Copilot, chat in agent mode | Free 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:
| Blueprint | Type | Fields (key: type) |
|---|---|---|
homepage | Singular resource | hero-title: Text, hero-subtitle: Text, hero-image: Image, intro: Rich text |
posts | Resource | title: Text (required), slug: Slug, excerpt: Text, cover: Image, content: Rich text |
team-members | Resource | name: Text (required), role: Text, photo: Image, bio: Rich text |
contact-submissions | Read-only resource | name: 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:
| Token | Abilities | Blueprints | Environment variable | Needed at |
|---|---|---|---|---|
| Site | view | homepage, posts, team-members | DIGGAMA_TOKEN | Build and runtime |
| Form | create | contact-submissions | DIGGAMA_FORM_TOKEN | Runtime |
| Preview | preview | homepage, posts, team-members | DIGGAMA_PREVIEW_TOKEN | Runtime, Draft Mode only |
| Your AI agent (MCP) | view, preview, create, update | All four (Read & write preset) | None; created in Step 2 and kept out of the repository | Your 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:
| Agent | Reads the project rules from | Extra step |
|---|---|---|
| Claude Code | AGENTS.md, natively since v2.1.277, but only when no CLAUDE.md or CLAUDE.local.md exists | On an older version, or if the project has a CLAUDE.md, put @AGENTS.md on the first line of CLAUDE.md |
| OpenAI Codex | AGENTS.md (native) | None |
| Cursor | AGENTS.md (native) | None |
| GitHub Copilot (agent mode) | AGENTS.md (chat.useAgentsMdFile, on by default) | None |
| Gemini CLI | GEMINI.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.
| Agent | Where the config lives | How 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 three | codex mcp list or /mcp |
Cursor (editor and agent CLI) | ~/.cursor/mcp.json, shared by both | agent mcp list, or Customize in the sidebar |
| Gemini CLI | ~/.gemini/settings.json with --scope user | gemini mcp list or /mcp |
| GitHub Copilot in VS Code | .vscode/mcp.json, token in VS Code’s secret storage | MCP: 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):
{
"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):
{
"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
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.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.
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):
# 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:
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 acacheoption a request is fetched once duringnext buildfor a static route, and on every request otherwise. Opting in and tagging every response withdiggamaanddiggama:<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'withcacheTag, but taggedfetchcalls do the same job here with less configuration, and they are what we tested on Workers. filter[published]=trueand theisLivecheck. Aviewtoken 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 comparespublished_atto the current time, to the second, so a post scheduled for 18:00 stays out of a build that runs at 17:59.isLiverepeats 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.
getPostfilters by slug rather than downloading every post and searching. That keeps each cached response small. Innext 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-storemakes 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).
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).// 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 }} />;
}
@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.
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).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).
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.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.
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.// 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';
}
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>
</>
);
}
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.
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.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.
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.'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.' };
}
}
'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>
);
}
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 againCreate 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.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);
}
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:
| Workflow | Trigger | Condition | Action: Send webhook, Destination URL |
|---|---|---|---|
| Revalidate homepage | Event | Resource type = homepage | https://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=homepage |
| Revalidate posts | Event | Resource type = posts | https://YOUR_SITE/api/revalidate?secret=SECRET&blueprint=posts |
| Revalidate team | Event | Resource type = team-members | https://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.
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.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,
});
{
"$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
}
}
{
"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"
}
}
/_next/static/*
Cache-Control: public,max-age=31536000,immutable
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:
| Setting | Value |
|---|---|
| Worker name | northwind-studio (must match name in wrangler.jsonc, see troubleshooting builds) |
| Build command | npx opennextjs-cloudflare build |
| Deploy command | npx opennextjs-cloudflare deploy |
| Build variables | DIGGAMA_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.
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.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);
}
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 buildlists one SSG route per published post, and no draft or scheduled slug - With more than 100 posts (or a lower
per_pageduring 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-submissionsand 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/draftshows a draft with the banner;/api/draft/exithides 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.
| Topic | Status with @opennextjs/cloudflare 1.20.9 | Source |
|---|---|---|
App Router, route handlers, Server Actions, SSG, ISR, revalidateTag | Supported | OpenNext |
Edge runtime (export const runtime = 'edge') | Not supported; remove it | OpenNext get started |
| Node.js middleware (added in Next.js 15.2) | Not yet supported | OpenNext |
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 one | Next.js proxy |
| Worker size | 3 MiB on Workers Free, 10 MiB on Workers Paid | OpenNext |
| Image optimization | Through the Cloudflare Images binding, billed separately | OpenNext images |
| Next.js 16.4.0 | Pages 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.
| Symptom | Likely cause | Fix |
|---|---|---|
Every page returns 500 on the Worker, log says Unexpected loadManifest(...) | Next.js version newer than the adapter supports | Pin the last version that works with npm run preview (16.3.8 here) |
| Publish in Diggama, page does not change | Fetch not tagged, Workflow failed, or wrong secret | Check the Workflow’s run history; make sure the page reads through lib/diggama.ts |
Build fails with Diggama 401 | Token mistyped, expired or revoked | Create a new token in Configuration › API Tokens (an expiration date cannot be extended) and update the variable |
Build fails with Diggama 403 | Token lacks the ability on that blueprint | Edit the token’s abilities in Configuration › API Tokens (the value stays the same) |
Build fails with DIGGAMA_TOKEN is not set | Variable missing in Workers Builds | Add it under Build variables and secrets |
| Pages built fine, but a new post returns 500 | DIGGAMA_TOKEN missing at runtime | npx wrangler secret put DIGGAMA_TOKEN |
| Blog shows exactly 25 posts | No pagination loop | Request per_page=100 and loop until meta.last_page |
429 RATE_LIMIT_EXCEEDED during a build | Over 1,000 requests per minute for the project | Wait error.details.retry_after seconds; reduce per-post requests |
next dev throws “items over 2MB can not be cached” | One list response is too large | Lower per_page for that list |
| A field renders nothing, no error | Wrong 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:
- Compare with the Astro version of the same site, which trades instant revalidation for a simpler static build.
- Read the pillar guide for the handover to the marketing team, roles, and the launch checklist.
- Tighten your project rules with the AGENTS.md template for websites.
- Let your agent seed and audit content through the Diggama MCP server.
- Sketch the content model of your own site with the content model generator, then create a Diggama project and follow the API introduction for your first request.