Quiverblocks chooses Diggama to deliver 200+ articles every month

Tutorial · 42 min read

Astro + headless CMS tutorial: build it with Claude Code or Codex

TL;DR

To run an Astro site on a headless CMS, prerender every page from the CMS API at build time (getStaticPaths for the blog), keep a single on-demand endpoint for the contact form, and deploy to Cloudflare Workers with the @astrojs/cloudflare adapter. A Diggama Workflow calls a Workers Builds deploy hook whenever content changes, so editors publish without a developer. Any AI coding agent, whether Claude Code, Codex, Cursor or another, can write each step from a short prompt, as long as every step ends with a passing build.

By Diggama Team Updated Tested with Astro 7.3@astrojs/cloudflare 14.3Wrangler 4.148Node.js 22.22TypeScript 6.0 View as Markdown

This tutorial builds Northwind Studio, the demo site of the Build a website with AI series, in Astro. The pillar guide explains the architecture and the content model; this page is the implementation, file by file. Every file shown here is complete, and the project was built with astro build and the Cloudflare adapter, then checked with astro check and wrangler deploy --dry-run, before publication. 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 gives you two things: the prompt to paste into your AI coding agent, and the code it should end up with. The prompts work the same in Claude Code, OpenAI Codex, Cursor, Gemini CLI and GitHub Copilot agent mode; only the setup in Step 1 differs per tool. You can prompt your way through and compare, or copy the files directly.

What are you building?

You are building a small company website whose every headline, post, photo and bio comes from Diggama, rendered to static HTML at build time and served by a Cloudflare Worker. One route runs on the server, the contact form endpoint, and a Diggama Workflow rebuilds the site whenever an editor saves content.

RouteContent from DiggamaRenderingFile
/homepage (singular) and the 3 latest postsPrerenderedsrc/pages/index.astro
/blog/postsPrerenderedsrc/pages/blog/index.astro
/blog/{slug}/One posts record per pagePrerendered with getStaticPathssrc/pages/blog/[slug].astro
/team/team-membersPrerenderedsrc/pages/team.astro
/contact/, /contact/thanks/None (form labels only)Prerenderedsrc/pages/contact/
/api/contact/Writes to contact-submissionsOn demand, on the Workersrc/pages/api/contact.ts

The finished project:

northwind-studio/
  AGENTS.md                 project rules for your agent (CLAUDE.md links to it)
  astro.config.mjs          adapter, sitemap, env schema
  wrangler.jsonc            Worker name, assets, preview environment
  package.json
  .env                      build-time values (not committed)
  .dev.vars                 runtime secrets for local dev (not committed)
  src/
    lib/diggama.ts          the typed API client
    lib/cms.ts              the client instance used by prerendered pages
    lib/sanitize.ts         allowlist for rich text
    lib/format.ts           date formatting
    components/RichText.astro
    layouts/Base.astro      HTML shell, SEO tags, navigation
    styles/global.css
    pages/index.astro
    pages/blog/index.astro
    pages/blog/[slug].astro
    pages/team.astro
    pages/contact/index.astro
    pages/contact/thanks.astro
    pages/api/contact.ts    the only on-demand route
    pages/robots.txt.ts

What do you need before you start?

You need Node.js 22.12 or later, an AI coding agent that can connect to a remote MCP server (Claude Code, Codex, Cursor, Gemini CLI or GitHub Copilot agent mode), a Diggama project with four blueprints, two or three API tokens, and a Cloudflare account connected to a GitHub or GitLab repository. Astro 7 declares node >=22.12.0 in its engines field, and Cloudflare’s build image defaults to Node.js 24.18.0, so both sides are covered.

Create these blueprints in Diggama under Configuration › Blueprints. The keys matter: Diggama derives each field key from its name (“Hero title” becomes hero-title), the key cannot be changed afterwards, and the code below reads these exact keys. The pillar guide explains each choice.

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

Then create tokens under Configuration › API Tokens (authentication docs):

  • Build token: view on homepage, posts and team-members. Used during astro build only.
  • Form token: create on contact-submissions and nothing else. Used by the contact endpoint at runtime.
  • Preview token (optional): preview on the three content blueprints, for the draft preview site.

The Starter plan includes one API key, Growth three, Enterprise five (pricing). Abilities are set per blueprint, so on Starter make one token with view on the three content blueprints and create on contact-submissions, and use it for the build, the form and, if you like, the MCP server in Step 1. It can read only what is already public and create submissions, so the extra exposure is small, but splitting the tokens is cleaner when your plan allows it. Diggama has a three-month free trial with no credit card; there is no free plan.

Step 1: How do you start an Astro project for an AI coding agent?

Scaffold the project yourself with create-astro, then open your agent in the folder. Scaffolding is one deterministic command; the agent is more useful once the project exists and it can read, build and fix it.

npm create astro@latest northwind-studio -- --template minimal --install --no-git --skip-houston --yes
cd northwind-studio
git init

In our test (create-astro 5.2.5, Astro 7.3.6), the minimal template already ships an AGENTS.md with instructions for AI agents, and a CLAUDE.md that is a symbolic link to it. That pair covers every major agent with one file, so append your content rules to AGENTS.md and nothing drifts:

AgentReads the project rules fromExtra step
Claude CodeCLAUDE.md, the link (it reads AGENTS.md only when no CLAUDE.md exists)None
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

Here is the complete AGENTS.md after this tutorial, with the generated part on top:

AGENTS.md
## Development

When starting the dev server, use background mode:

```
astro dev --background
```

Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.

## Documentation

Full documentation: https://docs.astro.build

Consult these guides before working on related tasks:

- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)

## Northwind Studio: content rules

All user-facing content lives in Diggama (headless CMS), never in this repo.

- Never hardcode headlines, paragraphs, images, posts or team members in
  components. Allowed hardcoded strings: navigation labels, form labels and
  form messages, the footer, and the meta descriptions of /blog/, /team/
  and /contact/.
- Every Diggama call goes through `src/lib/diggama.ts`. Prerendered pages use
  `cms` from `src/lib/cms.ts`. Pages never call fetch() on the API directly.
- Pages are prerendered. Only `src/pages/api/contact.ts` has
  `export const prerender = false`. Ask before adding another on-demand route.
- Field keys use hyphens: read them as `attributes['hero-title']`. Check keys
  with the `diggama` MCP server before writing code against a blueprint.
- Rich text is HTML: render it only through `src/components/RichText.astro`.
- No fallback copy. If a field is empty or missing, stop and tell me which
  field to add in Diggama.
- Env vars are declared in `astro.config.mjs` (`env.schema`) and read from
  `astro:env/server`. Never use `import.meta.env` for tokens.
- After each change: `npm run check` then `npm run build`, and fix until both pass.

The AGENTS.md template for websites explains each rule and gives a longer version.

Connect the Diggama and Astro docs MCP servers

Two MCP servers keep your agent accurate on this project: Diggama’s, so it reads the real blueprints instead of guessing field keys, and Astro’s documentation server, so it codes against Astro 7 rather than an older version it remembers. The Astro server URL comes from Astro’s guide to building with AI tools; it needs no token.

First get the Diggama token from Configuration › Connect to AI: click Customise per content type, tick preview on homepage, posts and team-members, and create on contact-submissions, then copy the token, which is shown once. Your agent can only describe a blueprint the token holds an ability on, and create lets it describe the form blueprint without reading the messages people send. A connection is an ordinary project token, so it also appears under Configuration › API Tokens; on Starter, you can use the single token from the previous section instead. The MCP guide covers the abilities, the write safeguards and team setups.

Then register both servers 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 --scope user diggama https://api.diggama.com/mcp \
  --header "Authorization: Bearer YOUR_DEV_TOKEN"
claude mcp add --transport http astro-docs https://mcp.docs.astro.build/mcp
claude

OpenAI Codex

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

export DIGGAMA_MCP_TOKEN=YOUR_DEV_TOKEN
codex mcp add diggama --url https://api.diggama.com/mcp --bearer-token-env-var DIGGAMA_MCP_TOKEN
codex mcp add astro-docs --url https://mcp.docs.astro.build/mcp
codex

The variable is named DIGGAMA_MCP_TOKEN, not DIGGAMA_TOKEN, so it cannot be confused with the build token in Step 3. Run this tutorial in the CLI, the IDE extension or the ChatGPT desktop app, the three surfaces that share this MCP configuration, rather than in a Codex cloud task, which runs in a cloud environment instead of on your machine.

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_DEV_TOKEN" }
    },
    "astro-docs": {
      "url": "https://mcp.docs.astro.build/mcp"
    }
  }
}

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_DEV_TOKEN" diggama https://api.diggama.com/mcp
gemini mcp add --transport http --scope user astro-docs https://mcp.docs.astro.build/mcp
gemini

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}" }
    },
    "astro-docs": {
      "type": "http",
      "url": "https://mcp.docs.astro.build/mcp"
    }
  }
}

VS Code now prefers a portable .mcp.json for new servers, but the ${input:...} password prompt is documented for this format. Chat apps are not a fit for this tutorial, which needs an agent that edits files and runs builds; for connecting ChatGPT or claude.ai to Diggama, see the MCP guide.

Once your agent lists both servers as connected, check the content model before any code is written:

Prompt for your AI agent
Read AGENTS.md. Using the diggama MCP server, describe the blueprints homepage, posts, team-members and contact-submissions. For each one, list every field key, its type and whether it is required, in one Markdown table per blueprint. Flag any key that is not exactly: hero-title, hero-subtitle, hero-image, intro (homepage); title, slug, excerpt, cover, content (posts); name, role, photo, bio (team-members); name, email, message (contact-submissions). Do not write any code.

Step 2: How do you add the Cloudflare adapter to Astro?

Run npx astro add cloudflare sitemap: it installs @astrojs/cloudflare and Wrangler, writes a wrangler.jsonc, and registers the adapter. Then set four options, because this site is static except for one route.

npx astro add cloudflare sitemap --yes
npm install sanitize-html
npm install -D @astrojs/check typescript@^6 @types/sanitize-html
npm run generate-types

Pin TypeScript to 6: @astrojs/check 0.9.10 declares a peer range of ^5.0.0 || ^6.0.0, while npm view typescript version already returns 7.0.2. In our run, astro add installed @astrojs/cloudflare 14.3.4 and Wrangler 4.148.0, added a generate-types script (wrangler types, which writes worker-configuration.d.ts), and created a wrangler.jsonc whose main is @astrojs/cloudflare/entrypoints/server, the entry point the Astro Cloudflare docs document for this version.

Prompt for your AI agent
Configure this Astro project for Cloudflare Workers with these constraints, then run npm run build and fix any error:
- output stays 'static'; only routes that export prerender = false run on the Worker
- @astrojs/cloudflare with imageService 'passthrough' (images come from the CMS CDN) and prerenderEnvironment 'node'
- disable sessions (session: false), this site has none
- trailingSlash 'always' and site set to https://www.northwind-studio.example
- @astrojs/sitemap, excluding /contact/thanks/
- declare DIGGAMA_API_URL (public, default https://api.diggama.com/v2), DIGGAMA_TOKEN (secret, optional), DIGGAMA_DRAFTS (public boolean, default false) and DIGGAMA_FORM_TOKEN (secret, optional) in env.schema
- add a "preview" environment to wrangler.jsonc
Use the astro-docs MCP server to check each option against the current docs.

The resulting configuration:

astro.config.mjs
// @ts-check
import { defineConfig, envField } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  // Your production URL: used for canonical links and the sitemap.
  site: 'https://www.northwind-studio.example',
  trailingSlash: 'always',

  // Pages are static by default. Only files that export
  // `prerender = false` (the contact endpoint) run on the Worker.
  output: 'static',
  adapter: cloudflare({
    // CMS images are already hosted and resized by their CDN,
    // so Astro does not need the Cloudflare Images binding.
    imageService: 'passthrough',
    prerenderEnvironment: 'node',
  }),
  session: false,

  integrations: [
    sitemap({
      filter: (page) => !page.includes('/contact/thanks/'),
    }),
  ],

  env: {
    schema: {
      // Build time: the API base URL and the read token.
      DIGGAMA_API_URL: envField.string({
        context: 'server',
        access: 'public',
        default: 'https://api.diggama.com/v2',
      }),
      DIGGAMA_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
      // Set to true on the preview Worker only: includes drafts.
      DIGGAMA_DRAFTS: envField.boolean({ context: 'server', access: 'public', default: false }),
      // Runtime: the create-only token used by the contact endpoint.
      DIGGAMA_FORM_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
    },
  },
});
wrangler.jsonc
{
	"$schema": "./node_modules/wrangler/config-schema.json",
	"name": "northwind-studio",
	"main": "@astrojs/cloudflare/entrypoints/server",
	"compatibility_date": "2026-10-06",
	"compatibility_flags": ["global_fetch_strictly_public"],
	"assets": {
		"directory": "./dist",
		"binding": "ASSETS"
	},
	"observability": {
		"enabled": true
	},
	"env": {
		// Built with CLOUDFLARE_ENV=preview, deployed as "northwind-studio-preview".
		"preview": {}
	}
}
package.json
{
  "name": "northwind-studio",
  "type": "module",
  "version": "0.0.1",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro",
    "generate-types": "wrangler types",
    "check": "astro check"
  },
  "dependencies": {
    "@astrojs/cloudflare": "^14.3.4",
    "@astrojs/sitemap": "^3.7.4",
    "astro": "^7.3.6",
    "sanitize-html": "^2.18.0",
    "wrangler": "^4.148.0"
  },
  "allowScripts": {
    "esbuild": true
  },
  "devDependencies": {
    "@astrojs/check": "^0.9.10",
    "@types/sanitize-html": "^2.16.2",
    "typescript": "^6.0.3"
  }
}

Why prerenderEnvironment is set to node

This is the one setting that decides whether your build works on Cloudflare. The current adapter prerenders pages inside workerd, Cloudflare’s runtime, instead of Node.js, by default. In our test, secrets declared with astro:env reached that prerenderer from the .env file (the build log prints “Using secrets defined in .env”), but not from process environment variables. A build with DIGGAMA_TOKEN exported in the shell, which is how Cloudflare passes build variables, failed with “Diggama token is missing”. With prerenderEnvironment: 'node', both cases pass. The adapter documents the option for prerendered pages that need Node.js; here it is what makes CI builds read their variables.

The three other choices

  • imageService: 'passthrough'. The adapter’s default is now cloudflare-binding, which transforms images on the Worker through the Cloudflare Images binding. Diggama returns finished image URLs, and the pages use plain <img> tags, so nothing needs transforming.
  • session: false. Without it, the adapter logs “Enabling sessions with Cloudflare KV with the SESSION KV binding” and adds a SESSION KV namespace to the generated Worker config, which Wrangler then provisions on deploy. This site has no sessions, so the namespace would be dead weight.
  • output: 'static'. Every page is prerendered unless its file exports prerender = false. Only src/pages/api/contact.ts does.

Step 3: How do you manage environment variables and secrets?

Declare every variable in env.schema and import it from astro:env/server. Build-time values go in .env locally and in Cloudflare’s build variables in CI; the form token is a runtime secret, in .dev.vars locally and in the Worker’s Variables & Secrets in production.

VariableRead byLocal fileOn CloudflareSensitive
DIGGAMA_API_URLBuild and Worker.env (optional)Build variable, only to overrideNo, inlined in the bundle
DIGGAMA_TOKENBuild.envBuild variableYes
DIGGAMA_DRAFTSBuild.env (preview only)Build variable on the preview WorkerNo
DIGGAMA_FORM_TOKENContact endpoint, per request.dev.varsRuntime secretYes

Two reasons to use astro:env rather than import.meta.env for tokens. First, a secret variable is never written into the built code: after the build, a search of dist/ for the token value found nothing, while the public DIGGAMA_API_URL did appear in the Worker chunk, as expected. Second, Cloudflare’s docs state that build variables are not available at runtime, so the build token cannot leak into the running Worker even by mistake.

Local files, both kept out of Git:

.env
# Build time. Leave DIGGAMA_API_URL unset to use https://api.diggama.com/v2
DIGGAMA_TOKEN=your-build-token-with-view
.dev.vars
# Runtime secrets for astro dev and astro preview
DIGGAMA_FORM_TOKEN=your-form-token-with-create-only

The generated .gitignore covers .env but not .dev.vars or Wrangler’s local state, so add both:

.gitignore
# build output
dist/
# generated types
.astro/

# dependencies
node_modules/

# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*


# environment variables
.env
.env.production

# macOS-specific files
.DS_Store

# jetbrains setting folder
.idea/

# Cloudflare local secrets and state
.dev.vars*
.wrangler/

Step 4: How do you write a typed Diggama client for Astro?

Put every API call in one module, src/lib/diggama.ts, that exports typed functions and knows the three rules of the API: lists are paginated, view and preview tokens see different records, and a singular blueprint comes back as a one-item list. Pages never call fetch() on the API themselves.

Prompt for your AI agent
Use the diggama MCP server to describe homepage, posts, team-members and contact-submissions. Then create src/lib/diggama.ts with no Astro imports:
- TypeScript types for each blueprint's attributes, using the exact field keys from the MCP output; image and rich text fields are string | null
- createDiggama({ baseUrl, token, drafts }) returning homepage(), posts() sorted by -published_at, teamMembers() sorted by name, and createContactSubmission(attributes)
- lists request per_page=100 and loop until meta.last_page; unless drafts is true, add filter[published]=true and drop records whose published_at is in the future
- homepage() reads data[0] of the list endpoint
- non-2xx responses throw a DiggamaError carrying status, error.code, error.details and error.request_id
Then create src/lib/cms.ts that builds the client from astro:env/server. Run npm run check.
src/lib/diggama.ts
// The only module that talks to the Diggama REST API.
// Docs: https://docs.diggama.com/resources-api
// It has no Astro imports, so it runs at build time and on the Worker alike.

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 };
};

type ErrorBody = {
  error?: { code?: string; message?: string; details?: unknown; request_id?: string };
};

// Field keys are the slugs Diggama derives from field names ("Hero title"
// becomes "hero-title"). Images are absolute URLs, rich text is HTML.
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;
};

export class DiggamaError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    readonly details: unknown,
    message: string,
  ) {
    super(message);
    this.name = 'DiggamaError';
  }
}

type Options = {
  baseUrl: string;
  token: string | undefined;
  /** true on the preview build: keep drafts and scheduled records. */
  drafts?: boolean;
};

export function createDiggama({ baseUrl, token, drafts = false }: Options) {
  if (!token) throw new Error('Diggama token is missing. Set it in .env or in the Cloudflare build variables.');
  const api = baseUrl.replace(/\/+$/, '');

  async function request<T>(path: string, init: { params?: Record<string, string>; body?: unknown } = {}): Promise<T> {
    const url = new URL(`${api}/${path}`);
    for (const [key, value] of Object.entries(init.params ?? {})) url.searchParams.set(key, value);

    const res = await fetch(url, {
      method: init.body === undefined ? 'GET' : 'POST',
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${token}`,
        ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
      },
      body: init.body === undefined ? undefined : JSON.stringify(init.body),
    });

    if (!res.ok) {
      const json = (await res.json().catch(() => ({}))) as ErrorBody;
      const code = json.error?.code ?? 'HTTP_ERROR';
      const message = json.error?.message ?? res.statusText;
      throw new DiggamaError(
        res.status,
        code,
        json.error?.details ?? [],
        `Diggama ${res.status} ${code} on ${url.pathname}${url.search}: ${message} (request ${json.error?.request_id ?? 'n/a'})`,
      );
    }
    return (await res.json()) as T;
  }

  // filter[published]=true already leaves out anything scheduled for later.
  // This second check costs nothing and also holds against a local mock API.
  const isLive = (record: DiggamaResource<unknown>) =>
    record.published_at !== null && new Date(record.published_at) <= new Date();

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

      const json = await request<ListResponse<A>>(`resources/${blueprint}`, { params: query });
      records.push(...json.data);
      if (page >= json.meta.last_page) break;
    }
    return drafts ? records : records.filter(isLive);
  }

  // A singular blueprint is served as a one-item list: read data[0].
  // It has no draft state, so no published filter applies.
  async function single<A>(blueprint: string) {
    const json = await request<ListResponse<A>>(`resources/${blueprint}`, { params: { per_page: '1' } });
    const record = json.data[0];
    if (!record) throw new Error(`Diggama: the singular blueprint "${blueprint}" returned no record.`);
    return record;
  }

  return {
    homepage: () => single<Homepage>('homepage'),
    posts: () => list<Post>('posts', { sort: '-published_at' }),
    teamMembers: () => list<TeamMember>('team-members', { sort: 'name' }),
    // Read-only blueprint: records are created through the API only,
    // and editors read them in the dashboard without being able to edit them.
    createContactSubmission: (attributes: ContactSubmission) =>
      request<{ data: DiggamaResource<ContactSubmission> }>('resources/contact-submissions', {
        body: { attributes },
      }),
  };
}
src/lib/cms.ts
// The client used by prerendered pages, at build time.
// Values come from .env locally and from the Cloudflare build variables in CI.
import { DIGGAMA_API_URL, DIGGAMA_DRAFTS, DIGGAMA_TOKEN } from 'astro:env/server';
import { createDiggama } from './diggama';

export const cms = createDiggama({
  baseUrl: DIGGAMA_API_URL,
  token: DIGGAMA_TOKEN,
  drafts: DIGGAMA_DRAFTS,
});

What each part does, with the documentation behind it:

  • Pagination. List endpoints return 25 records by default and at most 100 per page, with meta.last_page telling you when to stop (pagination). The loop requests 100 at a time, so 250 posts cost three requests. Our test fixture had 120 posts and the build requested two pages, as expected.
  • Published records only. A view token already sees only published records on a standard blueprint (publishing). The explicit filter[published]=true keeps production correct even if someone sets a preview token by mistake. The filter 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 on the client, which keeps the rule true against the local mock server described below.
  • The singular homepage. The blueprints docs say the V2 list endpoint returns a singular resource as a one-item list: read data[0]. It is always served, to view tokens too, and has no draft state, so no published filter applies.
  • Errors you can act on. Every V2 error has a stable code and a request_id (errors). The message includes both, so a failed Cloudflare build log tells you whether it was UNAUTHENTICATED, FORBIDDEN or INVALID_FILTER.
  • Two modules, two tokens. diggama.ts takes the token as an argument. Prerendered pages import cms.ts, which uses the build token; the contact endpoint creates its own client with the form token. The runtime code never touches the build token.

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

Fetch the singular record with cms.homepage() in the page frontmatter and render its attributes with bracket notation, because the keys contain hyphens. The page renders to static HTML at build time; nothing about Diggama reaches the browser except the image URLs.

Prompt for your AI agent
Create src/layouts/Base.astro (HTML shell with title, meta description, canonical URL from Astro.site, Open Graph tags, a nav with Home, Blog, Team, Contact, and a footer) and src/styles/global.css. Then build src/pages/index.astro from cms.homepage() and cms.posts(): hero-title as the h1, hero-subtitle under it, hero-image with width and height, intro through RichText, and the 3 latest posts. No fallback copy: if hero-title is empty, throw so the build fails. Run npm run build.
src/layouts/Base.astro
---
import '../styles/global.css';

interface Props {
  title: string;
  description?: string | null;
  image?: string | null;
  type?: 'website' | 'article';
  publishedAt?: string | null;
  noindex?: boolean;
}

const { title, description, image, type = 'website', publishedAt, noindex = false } = Astro.props;
const siteName = 'Northwind Studio';
const fullTitle = title === siteName ? title : `${title} | ${siteName}`;
const canonical = new URL(Astro.url.pathname, Astro.site);

const nav = [
  { href: '/', label: 'Home' },
  { href: '/blog/', label: 'Blog' },
  { href: '/team/', label: 'Team' },
  { href: '/contact/', label: 'Contact' },
];
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{fullTitle}</title>
    {description && <meta name="description" content={description} />}
    <link rel="canonical" href={canonical} />
    {noindex && <meta name="robots" content="noindex" />}
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <link rel="sitemap" href="/sitemap-index.xml" />

    <meta property="og:type" content={type} />
    <meta property="og:site_name" content={siteName} />
    <meta property="og:title" content={title} />
    {description && <meta property="og:description" content={description} />}
    <meta property="og:url" content={canonical} />
    {image && <meta property="og:image" content={image} />}
    <meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
    {type === 'article' && publishedAt && <meta property="article:published_time" content={publishedAt} />}
  </head>
  <body>
    <header class="site-header">
      <a href="/" class="logo">{siteName}</a>
      <nav aria-label="Main">
        {nav.map((item) => (
          <a href={item.href} aria-current={Astro.url.pathname === item.href ? 'page' : undefined}>{item.label}</a>
        ))}
      </nav>
    </header>
    <main>
      <slot />
    </main>
    <footer class="site-footer">
      <p>&copy; {new Date().getFullYear()} {siteName}</p>
    </footer>
  </body>
</html>
src/styles/global.css
:root {
  --text: #111827;
  --muted: #6b7280;
  --line: #e5e7eb;
  --accent: #2563eb;
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  color: var(--text);
  line-height: 1.6;
}

* { box-sizing: border-box; }
body { margin: 0; }
img { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); }

.site-header, .site-footer, main { max-width: 64rem; margin: 0 auto; padding: 1.5rem; }
.site-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.site-header nav { display: flex; gap: 1rem; }
.site-header nav a[aria-current='page'] { font-weight: 600; text-decoration: none; }
.logo { font-weight: 700; color: var(--text); text-decoration: none; }
.site-footer { color: var(--muted); border-top: 1px solid var(--line); }

.hero h1 { font-size: clamp(2rem, 5vw, 3.5rem); line-height: 1.1; margin: 0 0 1rem; }
.hero p { font-size: 1.25rem; color: var(--muted); }
.hero img, .cover { width: 100%; aspect-ratio: 1200 / 630; object-fit: cover; border-radius: 0.5rem; }

.cards { display: grid; gap: 2rem; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); padding: 0; list-style: none; }
.card h2, .card h3 { margin: 0.75rem 0 0.25rem; font-size: 1.25rem; }
.card time, .meta { color: var(--muted); font-size: 0.9rem; }
.portrait { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 50%; max-width: 10rem; }

.rich-text iframe { width: 100%; aspect-ratio: 16 / 9; border: 0; }
.rich-text table { border-collapse: collapse; }
.rich-text th, .rich-text td { border: 1px solid var(--line); padding: 0.5rem; }

form.contact { display: grid; gap: 1rem; max-width: 32rem; }
form.contact label { display: grid; gap: 0.25rem; font-weight: 600; }
form.contact input, form.contact textarea { font: inherit; padding: 0.6rem; border: 1px solid var(--line); border-radius: 0.375rem; }
form.contact button { font: inherit; padding: 0.7rem 1.2rem; background: var(--accent); color: #fff; border: 0; border-radius: 0.375rem; cursor: pointer; }
.hp { position: absolute; left: -9999px; }
.error { color: #b91c1c; }
src/pages/index.astro
---
import Base from '../layouts/Base.astro';
import RichText from '../components/RichText.astro';
import { cms } from '../lib/cms';

const [home, posts] = await Promise.all([cms.homepage(), cms.posts()]);
const hero = home.attributes;
const latest = posts.slice(0, 3);

// No fallback copy: an empty headline should fail the build, not ship a placeholder.
if (!hero['hero-title']) throw new Error('Diggama: homepage.hero-title is empty');
---

<Base title="Northwind Studio" description={hero['hero-subtitle']} image={hero['hero-image']}>
  <section class="hero">
    <h1>{hero['hero-title']}</h1>
    {hero['hero-subtitle'] && <p>{hero['hero-subtitle']}</p>}
    {hero['hero-image'] && (
      <img src={hero['hero-image']} alt="" width="1200" height="630" fetchpriority="high" />
    )}
  </section>

  <RichText html={hero.intro} />

  {latest.length > 0 && (
    <section>
      <h2>Latest posts</h2>
      <ul class="cards">
        {latest.map((post) => (
          <li class="card">
            <a href={`/blog/${post.attributes.slug}/`}>
              <h3>{post.attributes.title}</h3>
            </a>
            {post.attributes.excerpt && <p>{post.attributes.excerpt}</p>}
          </li>
        ))}
      </ul>
    </section>
  )}
</Base>

Three details worth keeping. No fallback strings: if an editor empties the headline, the build fails and the previous deploy keeps serving, which is better than shipping a placeholder nobody wrote. Escaping is automatic: Astro escapes {expressions}, and a test headline containing <product> rendered as &lt;product&gt;. Canonical URLs are built from site and the request path, so they carry the trailing slash that trailingSlash: 'always' enforces.

Step 6: How do you render rich text from the CMS safely?

Diggama returns rich text as an HTML string, so it has to go through set:html, and anything passed to set:html must be HTML you trust. Diggama sanitizes rich text server-side against an allowlist (scripts and event handlers are stripped, per the blueprints docs); a second allowlist pass at build time guarantees the page renders only the tags you chose.

Prompt for your AI agent
Create src/lib/sanitize.ts with a cleanHtml(html) function using sanitize-html: allow headings h2 to h4, paragraphs, lists, links, emphasis, blockquote, code, tables, img and iframe; only https and mailto URLs; iframes only from www.youtube.com, www.youtube-nocookie.com and player.vimeo.com; add rel="noopener noreferrer" to target="_blank" links and loading="lazy" to images and iframes. Then create src/components/RichText.astro that renders cleanHtml(html) with set:html and renders nothing for an empty field. Use it for every rich text field.
src/lib/sanitize.ts
// Diggama already strips scripts and event handlers from rich text.
// This second pass runs at build time, so it costs nothing at runtime,
// and it guarantees the page only ever renders the tags listed here.
import sanitizeHtml from 'sanitize-html';

export function cleanHtml(html: string | null | undefined): string {
  if (!html) return '';
  return sanitizeHtml(html, {
    allowedTags: [
      'h2', 'h3', 'h4', 'p', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's',
      'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
      'figure', 'figcaption', 'img', 'iframe',
      'table', 'thead', 'tbody', 'tr', 'th', 'td',
    ],
    allowedAttributes: {
      a: ['href', 'title', 'target', 'rel'],
      img: ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
      iframe: ['src', 'title', 'allow', 'allowfullscreen', 'loading'],
      th: ['colspan', 'rowspan'],
      td: ['colspan', 'rowspan'],
    },
    allowedSchemes: ['https', 'mailto'],
    // Video embeds from the editor: YouTube and Vimeo only.
    allowedIframeHostnames: ['www.youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com'],
    transformTags: {
      a: (tagName, attribs) => ({
        tagName,
        attribs: attribs.target === '_blank' ? { ...attribs, rel: 'noopener noreferrer' } : attribs,
      }),
      img: (tagName, attribs) => ({ tagName, attribs: { ...attribs, loading: 'lazy', decoding: 'async' } }),
      iframe: (tagName, attribs) => ({ tagName, attribs: { ...attribs, loading: 'lazy' } }),
    },
    allowedSchemesAppliedToAttributes: ['href', 'src'],
  });
}
src/components/RichText.astro
---
// Renders a Diggama rich text field (an HTML string) after sanitizing it.
import { cleanHtml } from '../lib/sanitize';

interface Props {
  html: string | null | undefined;
  class?: string;
}

const { html, class: className } = Astro.props;
const clean = cleanHtml(html);
---

{clean && <div class:list={['rich-text', className]} set:html={clean} />}

Because the pages are prerendered, sanitizing happens once per build and adds nothing to page weight or response time. In the test build, an intro containing <script>, an onclick attribute and a YouTube iframe came out with the script and the handler removed and the iframe kept, with loading="lazy" added. Never pass a plain Text field to set:html; render it as {value} and let Astro escape it.

Step 7: How do you generate the blog with getStaticPaths?

Fetch all posts once in getStaticPaths, return one entry per post with the record as a prop, and Astro writes one HTML file per post. The list page uses the same cms.posts() call.

Prompt for your AI agent
Create src/lib/format.ts with formatDate(iso) for published_at values (en-GB, long date, Europe/Paris time zone, "Draft" when null). Build src/pages/blog/index.astro listing cms.posts() with cover, title, date and excerpt. Build src/pages/blog/[slug].astro with getStaticPaths over cms.posts(), passing each post as a prop (no second request per post). Render content with RichText, set the meta description from excerpt, and add BlogPosting JSON-LD with "<" escaped. Run npm run build and confirm that drafts and scheduled posts have no HTML file in dist/client/blog.
src/lib/format.ts
// Formats a Diggama published_at value ("2026-02-01T13:00:00+01:00") for display,
// in the site's time zone: in UTC, a post published at 00:30 in Paris would show
// the previous day. Change Europe/Paris to your own zone.
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'Europe/Paris' });

export function formatDate(iso: string | null): string {
  if (!iso) return 'Draft';
  return dateFormat.format(new Date(iso));
}
src/pages/blog/index.astro
---
import Base from '../../layouts/Base.astro';
import { cms } from '../../lib/cms';
import { formatDate } from '../../lib/format';

const posts = await cms.posts();
---

<Base title="Blog" description="Notes on design, product and building websites from the Northwind Studio team.">
  <h1>Blog</h1>
  <ul class="cards">
    {posts.map((post) => (
      <li class="card">
        <a href={`/blog/${post.attributes.slug}/`}>
          {post.attributes.cover && (
            <img class="cover" src={post.attributes.cover} alt="" width="1200" height="630" loading="lazy" decoding="async" />
          )}
          <h2>{post.attributes.title}</h2>
        </a>
        <time datetime={post.published_at ?? undefined}>{formatDate(post.published_at)}</time>
        {post.attributes.excerpt && <p>{post.attributes.excerpt}</p>}
      </li>
    ))}
  </ul>
</Base>
src/pages/blog/[slug].astro
---
import type { GetStaticPaths } from 'astro';
import Base from '../../layouts/Base.astro';
import RichText from '../../components/RichText.astro';
import { cms } from '../../lib/cms';
import type { DiggamaResource, Post } from '../../lib/diggama';
import { formatDate } from '../../lib/format';

// One request for all posts at build time, then one HTML file per post.
export const getStaticPaths = (async () => {
  const posts = await cms.posts();
  return posts.map((post) => ({
    params: { slug: post.attributes.slug },
    props: { post },
  }));
}) satisfies GetStaticPaths;

const { post } = Astro.props as { post: DiggamaResource<Post> };
const { title, excerpt, cover, content } = post.attributes;

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: title,
  description: excerpt ?? undefined,
  image: cover ?? undefined,
  datePublished: post.published_at ?? undefined,
  dateModified: post.updated_at,
  url: new URL(Astro.url.pathname, Astro.site).href,
  publisher: { '@type': 'Organization', name: 'Northwind Studio' },
};
// Escape "<" so a title containing "</script>" cannot close the tag.
const jsonLdHtml = JSON.stringify(jsonLd).replace(/</g, '\\u003c');
---

<Base title={title} description={excerpt} image={cover} type="article" publishedAt={post.published_at}>
  <article>
    <p class="meta"><time datetime={post.published_at ?? undefined}>{formatDate(post.published_at)}</time></p>
    <h1>{title}</h1>
    {cover && <img class="cover" src={cover} alt="" width="1200" height="630" fetchpriority="high" />}
    <RichText html={content} />
  </article>
  <script type="application/ld+json" set:html={jsonLdHtml} />
</Base>

Passing the record as a prop matters at scale. Fetching each post again by slug would cost one API request per page, and Diggama’s rate limit is 1,000 requests per minute per project, shared by every token. With props, the whole blog costs one paginated list.

The test fixture had 120 posts, including one draft, one scheduled three days ahead and one scheduled two hours ahead. The build wrote 117 post pages and none of the three. Rebuilding with the preview token and DIGGAMA_DRAFTS=true wrote all 120.

Two more details. Treat slug as unique: two records with the same slug compete for one URL. And the JSON-LD string escapes <, so a post titled with </script> cannot close the script tag early.

Step 8: How do you build the team page?

The team page is the same pattern with a different blueprint: one cms.teamMembers() call, sorted by name, and a card per member.

src/pages/team.astro
---
import Base from '../layouts/Base.astro';
import RichText from '../components/RichText.astro';
import { cms } from '../lib/cms';

const members = await cms.teamMembers();
---

<Base title="Team" description="The people behind Northwind Studio.">
  <h1>Team</h1>
  <ul class="cards">
    {members.map((member) => (
      <li class="card">
        {member.attributes.photo && (
          <img
            class="portrait"
            src={member.attributes.photo}
            alt={`Portrait of ${member.attributes.name}`}
            width="320"
            height="320"
            loading="lazy"
            decoding="async"
          />
        )}
        <h2>{member.attributes.name}</h2>
        {member.attributes.role && <p class="meta">{member.attributes.role}</p>}
        <RichText html={member.attributes.bio} />
      </li>
    ))}
  </ul>
</Base>

A team member saved as a draft does not appear: in the test, a member with no published_at was absent from the production build and present in the preview build. That lets editors prepare a new hire’s profile before the announcement.

How should you handle CMS images in Astro?

Render Diggama image fields with a plain <img> and explicit width and height: the API returns an absolute URL (field types) and nothing else, no dimensions and no alt text. Explicit dimensions plus CSS aspect-ratio and object-fit: cover reserve the space and prevent layout shift whatever size the editor uploads.

  • Above the fold, the hero and post covers use fetchpriority="high"; everything else uses loading="lazy" and decoding="async".
  • Alt text is empty for decorative images (hero, covers) and built from the name on team photos. If editors need to write alt text, add a Text field such as hero-image-alt next to the image; the API has no alt property on image fields.
  • Resizing with Astro. If you want Astro to generate resized variants, the adapter’s compile image service optimizes at build time and cloudflare-binding transforms on the Worker through the Images binding (adapter docs). Either way you must authorize the CDN host in image.domains or image.remotePatterns. This tutorial keeps passthrough to keep the build simple.

Step 9: How do you add SEO metadata and a sitemap?

Base.astro already writes the title, meta description, canonical URL and Open Graph tags from CMS fields, and @astrojs/sitemap writes sitemap-index.xml from the prerendered routes. Add a robots.txt that points crawlers to it.

src/pages/robots.txt.ts
import type { APIRoute } from 'astro';

// Prerendered like every page; points crawlers to the sitemap.
export const GET: APIRoute = ({ site }) => {
  const sitemap = new URL('sitemap-index.xml', site);
  return new Response(`User-agent: *\nAllow: /\n\nSitemap: ${sitemap.href}\n`, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
};

What the build produced: sitemap-index.xml and sitemap-0.xml in dist/client, every URL with a trailing slash, /contact/thanks/ excluded by the filter in astro.config.mjs, and /api/contact/ absent because the sitemap only lists pages. The thanks page also carries noindex. Post pages get og:type article, article:published_time and BlogPosting JSON-LD, and the excerpt becomes the meta description, so ask editors to write excerpts of 140 to 160 characters. Once deployed, run the live URL through the website grader.

Step 10: How do you build the contact form with an on-demand endpoint?

Keep the form page static and post it to an endpoint that exports prerender = false. The endpoint validates the input, then creates a record in the read-only contact-submissions blueprint with POST /v2/resources/contact-submissions (create resources), using a create-only token read from a runtime secret. The token never reaches the browser.

Prompt for your AI agent
Add a contact form. src/pages/contact/index.astro is a static page with a form (name, email, message, plus a hidden honeypot field named website) that POSTs to /api/contact/ with a trailing slash. src/pages/api/contact.ts exports prerender = false and a POST handler that: returns a 303 to /contact/thanks/ without storing anything if the honeypot is filled; validates name (1 to 200 chars), email (simple pattern, max 254) and message (1 to 5000 chars) and redirects to /contact/?error=invalid on failure; otherwise calls createContactSubmission with a client built from DIGGAMA_FORM_TOKEN and DIGGAMA_API_URL from astro:env/server. On a DiggamaError, log it and redirect to ?error=invalid for a 422, ?error=server otherwise. Any other method returns 405. Add src/pages/contact/thanks.astro with noindex. Test with curl against npx astro preview.
src/pages/contact/index.astro
---
import Base from '../../layouts/Base.astro';
---

<Base title="Contact" description="Tell us about your project. We answer within two working days.">
  <h1>Contact</h1>
  <p id="form-error" class="error" role="alert" hidden>
    Please check the fields: a name, a valid email and a message under 5,000 characters are required.
  </p>
  <p id="server-error" class="error" role="alert" hidden>
    Something went wrong on our side. Please try again in a minute.
  </p>

  <form class="contact" method="post" action="/api/contact/">
    <label>
      Name
      <input name="name" autocomplete="name" required maxlength="200" />
    </label>
    <label>
      Email
      <input name="email" type="email" autocomplete="email" required maxlength="254" />
    </label>
    <label>
      Message
      <textarea name="message" rows="6" required maxlength="5000"></textarea>
    </label>
    <!-- Honeypot: hidden from people, filled in by most bots. -->
    <label class="hp" aria-hidden="true">
      Website
      <input name="website" tabindex="-1" autocomplete="off" />
    </label>
    <button type="submit">Send</button>
  </form>

  <script>
    const error = new URLSearchParams(location.search).get('error');
    if (error === 'invalid') document.getElementById('form-error')?.removeAttribute('hidden');
    if (error === 'server') document.getElementById('server-error')?.removeAttribute('hidden');
  </script>
</Base>
src/pages/contact/thanks.astro
---
import Base from '../../layouts/Base.astro';
---

<Base title="Message sent" noindex>
  <h1>Thanks, your message is on its way</h1>
  <p>We answer within two working days. <a href="/">Back to the homepage</a></p>
</Base>
src/pages/api/contact.ts
// The only route that runs on the Worker at request time.
// It validates the form and creates a record in the read-only
// contact-submissions blueprint with a create-only token.
import type { APIRoute } from 'astro';
import { DIGGAMA_API_URL, DIGGAMA_FORM_TOKEN } from 'astro:env/server';
import { createDiggama, DiggamaError } from '../../lib/diggama';

export const prerender = false;

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

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const field = (key: string) => String(form.get(key) ?? '').trim();

  // Honeypot filled in: answer like a success, store nothing.
  if (field('website')) return redirect('/contact/thanks/', 303);

  const name = field('name');
  const email = field('email');
  const message = field('message');

  const valid =
    name.length > 0 && name.length <= 200 &&
    EMAIL.test(email) && email.length <= 254 &&
    message.length > 0 && message.length <= 5000;
  if (!valid) return redirect('/contact/?error=invalid', 303);

  try {
    const diggama = createDiggama({ baseUrl: DIGGAMA_API_URL, token: DIGGAMA_FORM_TOKEN });
    await diggama.createContactSubmission({ name, email, message });
  } catch (error) {
    // Shows up in Workers Logs (observability is on in wrangler.jsonc).
    console.error('contact form:', error instanceof DiggamaError ? error.message : error);
    const status = error instanceof DiggamaError && error.status === 422 ? 'invalid' : 'server';
    return redirect(`/contact/?error=${status}`, 303);
  }

  return redirect('/contact/thanks/', 303);
};

// Anything other than POST gets a 405 with the allowed method.
export const ALL: APIRoute = () =>
  new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });

How it behaved under astro preview, which runs the Worker in workerd locally:

RequestResponse
Valid POST /api/contact/303 to /contact/thanks/, and a record created in contact-submissions
Invalid email303 to /contact/?error=invalid, nothing sent to Diggama
Honeypot filled303 to /contact/thanks/, nothing stored
GET /api/contact/405 with Allow: POST
POST /api/contact (no trailing slash)308 to /api/contact/

The last row is why the form action ends with a slash. With trailingSlash: 'always', a request without it gets a redirect, and a form post that hits a redirect depends on the browser to resend the body. Point the form at the canonical URL and the question never comes up.

Astro also checks the Origin header of form posts to on-demand routes by default (security.checkOrigin): in our test, the same POST sent with Origin: https://evil.example got a 403, so other sites cannot post into your form. Requests without an Origin header, like the curl call below, pass.

The endpoint redirects with 303 instead of returning JSON, so the form works without JavaScript; the only script on the page reads ?error= to show a message. Diggama validates too: an email field must hold a valid address, and a failed check returns 422 VALIDATION_ERROR with messages per field. Validating in the endpoint first keeps spam and malformed input out of your project.

To notify the team of each message, add a Diggama Workflow with an Event trigger, the conditions Event type is Resource Created and Resource type is contact-submissions, and a Send email action. The email body can insert fields with placeholders such as {{name}} and {{email}}.

How do you test the site locally?

Run the dev server for day-to-day work, then astro check, astro build and astro preview before pushing. astro preview serves the build in workerd, the same runtime as production, so it is the real test of the contact endpoint.

npm run dev
npm run check
npm run build
npx astro preview
curl -i -X POST -d "name=Test&[email protected]&message=Hello" http://localhost:4321/api/contact/

Astro 7’s dev server can run in the background (astro dev --background, then astro dev logs and astro dev stop), which is what the generated AGENTS.md tells your agent to do, so it can start the server, curl a page and read the logs without blocking the session. Astro’s AI guide adds that astro dev and astro preview detach on their own when they detect a coding agent on macOS or Linux; stop the preview server with astro preview stop.

A testing tip: point DIGGAMA_API_URL at a local mock server while you develop, and your tests never touch production content. This tutorial was built that way, against a small node:http server returning the documented response shapes (data, meta and links for lists, 201 with data for a create, the error object for failures). Because the client reads the base URL from one variable, switching back is a one-line change in .env.

Prompt for your AI agent
Create mock/server.mjs, a node:http server on port 4010 that imitates the Diggama V2 API for this project: GET /v2/resources/{blueprint} with per_page, page, sort and filter[published], returning data, meta and links as documented at https://docs.diggama.com/pagination; POST /v2/resources/contact-submissions returning 201 with data, or 422 VALIDATION_ERROR in the documented error format. Seed 120 posts including one draft and two scheduled in the future, three team members including a draft, and a homepage whose intro contains a script tag. Do not import it from src/.

Step 11: How do you deploy Astro to Cloudflare Workers?

Connect the repository to a Cloudflare Worker with Workers Builds: every push to the production branch runs npm run build, then npx wrangler deploy, which uploads the prerendered pages as static assets and the contact endpoint as Worker code. Put the build token in build variables and the form token in runtime secrets.

OptionFits this site?Why
Workers with static assets and @astrojs/cloudflareYesStatic pages plus one on-demand route; the adapter targets Workers
Workers with static assets, no adapterOnly without the formAstro docs: a fully static site needs no adapter
Cloudflare PagesNoThe Astro adapter no longer supports Pages

Cloudflare’s own Astro guide uses Workers for both static and on-demand Astro sites. Steps:

  1. Push the repository to GitHub or GitLab.
  2. In the Cloudflare dashboard, open Workers & Pages, create a Worker from your repository, and name it northwind-studio. The name must match name in wrangler.jsonc: Cloudflare’s Workers Builds docs say the build fails otherwise.
  3. Set the build command to npm run build. The deploy command defaults to npx wrangler deploy; keep it.
  4. Under Settings > Build > Build variables and secrets, add DIGGAMA_TOKEN with the build token. Add NODE_VERSION only if you want to pin a version other than the image default (24.18.0 in the build image docs).
  5. After the first deploy, add the form token as a runtime secret, in Settings > Variables & Secrets or from your terminal:
npx wrangler secret put DIGGAMA_FORM_TOKEN
  1. Submit the form once on the workers.dev URL and check that the record appears in contact-submissions. Then add your custom domain.

Before the first push, ask your agent to check the deploy offline. wrangler deploy --dry-run needs no Cloudflare login; on this project it listed the Worker modules, the static asset files and a single binding, ASSETS:

Prompt for your AI agent
Run npm run build, then npx wrangler deploy --dry-run. Report the Worker name, the bindings and the number of asset files. Confirm that no KV namespace or Images binding is listed, that dist/ contains no Diggama token (search for the value in .env), and that the Worker name matches the name I will use in the Cloudflare dashboard: northwind-studio.

If the build passes locally but fails on Cloudflare with “Diggama token is missing”, check that prerenderEnvironment: 'node' is in astro.config.mjs and that DIGGAMA_TOKEN is set under build variables, not under the Worker’s runtime variables.

Step 12: How do you rebuild the site when an editor publishes?

Create a deploy hook on the Worker, then add a Diggama Workflow per content blueprint whose Send webhook action posts to it. Every create, update or delete in homepage, posts or team-members starts a build, and the site is current as soon as the build finishes.

flowchart LR
    editor["Editor saves"] --> workflow["Diggama Workflow"]
    workflow -->|"Send webhook"| hook["Cloudflare deploy hook"]
    hook --> build["Workers Builds: npm run build"]
    build -->|"reads content"| api["REST API (build token)"]
    build -->|"wrangler deploy"| site["Worker serves the new HTML"]

Create the deploy hook

In the Worker, go to Settings > Builds > Deploy Hooks, name the hook, select the production branch and copy the URL. Per Cloudflare’s deploy hooks docs, the URL needs no authorization header (the ID in it is the credential, so keep it secret), a second call while a build is still queued or initializing does not create a second build, and hooks are rate limited to 10 builds per minute per Worker. Deploy hooks for Workers Builds were announced on April 1, 2026. Test it from your terminal:

curl -X POST "https://api.cloudflare.com/client/v4/workers/builds/deploy_hooks/YOUR_HOOK_ID"

Create the Workflows in Diggama

In Diggama, open Configuration › Workflows and create one Workflow per content blueprint:

  • Trigger type: Event
  • Condition: Resource type is posts (then homepage, then team-members)
  • Action: Send webhook, with the deploy hook URL as the destination

A Workflow’s conditions must all match, so one Workflow cannot cover several blueprints. Do not create a Workflow without a resource type condition: it would also fire on every contact form submission and rebuild the site for each message.

What the webhook sends: a POST with the body {"event": "resource_updated"} (or resource_created, resource_deleted), no resource data and no signature, a 10-second timeout, and two attempts half a second apart. The deploy hook needs nothing more than the request itself, and the build fetches all content again anyway.

Behaviors to tell your editors:

  • Saving a draft triggers a build. An explicit save is an update event, even on a draft. The build leaves drafts out, so nothing leaks; it only costs a build. Autosave does not fire workflows.
  • Scheduled posts publish themselves. When a post’s published_at arrives, Diggama fires an update event within about five minutes, the hook starts a build, and the post appears.
  • Bursts are absorbed. Ten quick saves while a build is queued produce one build. If a build is already running, the next call queues another, so the last change is always included.
  • The homepage has no draft state. Every save of the singular homepage goes live with the next build.

When something does not update, check both ends: the Workflow’s Execution history in Diggama shows whether the webhook succeeded, and the Worker’s build log in Cloudflare shows whether the build ran and why it failed.

How do you preview drafts before they are published?

Deploy the same repository a second time as a preview Worker, built with a Diggama token that has the preview ability and with DIGGAMA_DRAFTS=true, then put it behind Cloudflare Access. Editors get a full copy of the site that includes drafts and scheduled posts, while production never holds a preview token. This step is optional.

The preview environment in wrangler.jsonc is already in place. With the Cloudflare Vite plugin, which the Astro adapter uses, the environment is selected at build time through the CLOUDFLARE_ENV variable (Wrangler environments), and the Worker name becomes northwind-studio-preview. A local check:

CLOUDFLARE_ENV=preview DIGGAMA_TOKEN=your-preview-token DIGGAMA_DRAFTS=true npm run build
npx wrangler deploy --env preview --dry-run

Then in Cloudflare:

  1. Create a second Worker from the same repository, named northwind-studio-preview. Cloudflare’s advanced setups page describes connecting one repository to a Worker per environment.
  2. Build command: CLOUDFLARE_ENV=preview npm run build. Deploy command: npx wrangler deploy --env preview.
  3. Build variables: DIGGAMA_TOKEN set to the preview token, and DIGGAMA_DRAFTS set to true. Leave out the form token, so the preview site cannot create real submissions.
  4. In the preview Worker’s Settings > Domains & Routes, click Enable Cloudflare Access for the workers.dev URL (one-click Access for Workers) and allow only your team.
  5. Create a deploy hook on the preview Worker and add a second Send webhook action to each Diggama Workflow. Actions run in order, and a failed action does not stop the next one.

In the test build with the preview token and drafts enabled, the draft post, both scheduled posts and the draft team member all appeared, with “Draft” shown in place of a date for the unpublished post.

What errors will you hit, and how do you fix them?

Most failures come from configuration at the boundaries (tokens, build variables, field keys, URLs), not from the Astro code. These are the ones the build above surfaces, with the exact fix.

SymptomCauseFix
Build fails on Cloudflare with “Diggama token is missing”, works locallyPrerendering in workerd did not receive the process environmentprerenderEnvironment: 'node'; token under build variables
Diggama 401 UNAUTHENTICATED in the build logToken missing, mistyped, expired, revoked or regeneratedCopy the current value into the build variable and rebuild
Diggama 403 FORBIDDENToken lacks the ability on that blueprintEdit the token’s abilities in Configuration › API Tokens
Diggama 404 RESOURCE_NOT_FOUND on a listBlueprint slug in the URL is wrong, for example team_membersUse the slug shown by the diggama MCP server
Diggama 422 INVALID_FILTERFilter on a field key that does not existCheck the key with the diggama MCP server
A field renders nothing, no errorWrong key, for example heroTitle instead of hero-titleBracket notation with the exact key; regenerate the types from MCP
The blog stops at 25 postsOne request without paginationper_page=100 and loop until meta.last_page
Form posts get a 308Action URL without the trailing slashPost to /api/contact/
Form posts get a 403The form is posted from another origin (Astro’s checkOrigin)Serve the form from the same domain as the endpoint
Contact form always shows the server errorDIGGAMA_FORM_TOKEN not set as a runtime secretnpx wrangler secret put DIGGAMA_FORM_TOKEN
Deploy creates a KV namespace you did not ask forAdapter enables sessions by defaultsession: false
Build fails with a Worker name errorDashboard name differs from wrangler.jsoncUse the same name in both
Publishing does not update the siteNo Workflow for that blueprint, or wrong conditionCheck Execution history in Diggama and the build log

Where do you go from here?

You now have a static Astro site whose content belongs to the editors, a single server route with the narrowest token possible, and rebuilds that run on their own. From here:

To start, create a Diggama project (three months free, no credit card), add the four blueprints from the table above, and run the first prompt.

FAQ

Frequently Asked Questions

Something else? Get in touch.

Do I need the Cloudflare adapter for a static Astro site?

Not if every page is static. Astro's documentation says a static site needs no adapter, and Cloudflare can serve the dist folder as Worker static assets. You need @astrojs/cloudflare as soon as one route runs at request time, like the contact form endpoint in this tutorial.

Should I deploy Astro to Cloudflare Pages or Cloudflare Workers?

Workers. The current @astrojs/cloudflare adapter no longer supports Cloudflare Pages, and Cloudflare's own Astro guide deploys to Workers with static assets. Workers Builds also has deploy hooks since April 2026, which is what lets a CMS trigger a rebuild.

Why fetch CMS content at build time instead of on each request?

Prerendered pages are plain HTML served from Cloudflare's edge, so they are fast, they never expose an API token, and a CMS outage cannot take the live site down. The build also spends a handful of API requests per deploy instead of one per visit. The cost is a rebuild after each publish, which a deploy hook automates.

How long does it take for a published change to appear on the site?

As long as one build and deploy of your project, plus the time the build waits in the queue. Diggama sends the webhook from a background job right after the save, and Cloudflare queues a build from the deploy hook. Time a build once on your own project and tell editors the real number.

Is set:html safe for rich text from a headless CMS?

It is safe when you control what reaches it. Diggama sanitizes rich text server-side against an allowlist, and this tutorial runs a second allowlist pass with sanitize-html at build time, which costs nothing at runtime. Never pass plain text fields to set:html: render them with normal Astro expressions, which escape HTML.

Which AI coding agents can follow this Astro tutorial?

Any agent that edits files, runs npm commands and connects to a remote MCP server with an Authorization header: Claude Code, OpenAI Codex, Cursor, GitHub Copilot agent mode and Gemini CLI all do. The prompts are the same for every agent; only the MCP setup and the instructions file differ, and the tutorial gives each one.

Can Claude Code, Codex or Cursor build the whole Astro site from one prompt?

They can, but the result is harder to review and tends to include hardcoded fallback copy. Prompting one step at a time, with a passing build at the end of each step and the content rules written in AGENTS.md, gives you smaller diffs and catches wrong field keys early.

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