# Why AI-built websites need a CMS: vibe coding in month two

> You need a CMS on a site built with Claude Code, Codex, Cursor or any other AI coding agent as soon as someone other than the developer has to change its content. Generated sites hardcode copy in components, so every typo becomes a prompt, a diff review and a deploy, with no drafts, scheduling, translation workflow or form inbox. A headless CMS moves that content into typed fields the team edits and the site fetches at build time; a one-page site or a developer's Markdown blog in Git does not need one.

Source: https://diggama.com/guides/why-ai-built-websites-need-a-cms/
Last updated: 2026-10-07

Here is a story that plays out a lot in 2026. Someone opens an AI coding agent (Claude Code, OpenAI Codex, Cursor) on a Friday, describes the company website, and by Sunday has an Astro site deployed on Cloudflare that loads in under a second and looks better than the old Webflow build. It is a real achievement. [Andrej Karpathy named the practice "vibe coding"](https://en.wikipedia.org/wiki/Vibe_coding) in February 2025, and Collins Dictionary made it [its Word of the Year for 2025](https://blog.collinsdictionary.com/language-lovers/collins-word-of-the-year-2025-ai-meets-authenticity-as-society-shifts/).

Then month two arrives. Marketing wants to change a headline, publish a post on Tuesday at 9:00, add a French version, and find out why nobody has replied to the contact form in three weeks. None of that is a coding problem, and all of it now needs a developer.

Below are the seven ways an AI-built website fails after launch, each with the code that causes it and the code that fixes it, followed by when you do not need a CMS and a 30-minute retrofit. The examples use Northwind Studio, the demo site from our [guide to building a website with AI](/guides/build-a-website-with-ai/), with content in Diggama.

## What goes wrong with an AI-built website after launch?

The code usually keeps working. What fails is everything around the content: who can change it, when it goes live, in which languages, and where visitor input ends up. A site generated in a weekend has no answer to those questions, because the agent was asked to build pages, not an editorial process.

| # | Failure mode | First symptom | What fixes it |
|---|---|---|---|
| 1 | Marketers cannot edit anything | A typo fix waits for a developer | Copy in CMS fields, fetched at build time |
| 2 | The agent rewrites more than you asked | A layout change also rewrites the headline | No copy in components to rewrite |
| 3 | No drafts, review or scheduling | Someone deploys at 9:00 on launch day | Publication states in the CMS, rebuild on publish |
| 4 | Blog and SEO pages do not scale as files | A `posts.ts` file that grows with every post | One template, many records |
| 5 | Translations drift | French pages show English sentences | One record with a variant per language |
| 6 | Form submissions go nowhere | A thank-you message, and no message received | A server endpoint that stores submissions |
| 7 | The person who prompted the site leaves | Nobody knows where the token or the deploy lives | Company-owned accounts, roles, an `AGENTS.md` |

## Why can't marketers edit a site an AI coding agent built?

Because the copy is code. An AI coding agent writes the shortest path to a page that renders, and that path puts every headline, paragraph and image path directly into the component file, where only someone with the repository, a terminal and a deploy pipeline can change it.

This is what a generated hero typically looks like:

```astro title="src/components/Hero.astro"
---
// Generated in the first session: every word lives in the component.
---

<section class="hero">
  <h1>Websites that work as hard as you do</h1>
  <p>Northwind Studio designs and builds websites for growing companies in Lyon and Paris.</p>
  <img src="/images/hero.jpg" alt="The Northwind Studio team at work" width="1200" height="630" />
  <a class="button" href="/contact/">Get a quote</a>
</section>
```

Changing "Lyon and Paris" to "Lyon, Paris and Geneva" means a terminal, a prompt or an edit, a diff review, a commit and a build. Fine once, not every week, and the people who want the change are rarely the people who can make it.

The same component, with the copy moved to a `homepage` blueprint in Diggama:

```astro title="src/components/Hero.astro"
---
// Copy comes from the homepage blueprint in Diggama. Only layout lives here.
import { cms } from '../lib/cms';

const { attributes: hero } = await cms.homepage();

// No fallback copy: an empty headline fails the build instead of shipping a placeholder.
if (!hero['hero-title']) throw new Error('Diggama: homepage.hero-title is empty');
---

<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" />}
  <a class="button" href="/contact/">Get a quote</a>
</section>
```

`cms` is the build-time client from the [Astro tutorial](/guides/build-a-website-with-ai/astro/). An editor now changes the subtitle in a form, clicks Save, and a webhook rebuilds the site. "Get a quote" is still hardcoded because the tutorial's model has no field for it. That is a modeling decision: if marketing will test button labels, add a `cta-label` text field.

## Why does the agent change copy you did not ask it to change?

Because when copy and layout share a file, any edit to the layout is also an opportunity to edit the copy. The agent reads the whole component, and a request like "tighten the hero" is ambiguous enough to cover the words as well as the spacing.

Here is a realistic diff for the prompt "Make the hero more compact on mobile" against the hardcoded component:

```diff title="src/components/Hero.astro"
-<section class="hero">
-  <h1>Websites that work as hard as you do</h1>
-  <p>Northwind Studio designs and builds websites for growing companies in Lyon and Paris.</p>
+<section class="hero hero--compact">
+  <h1>Websites that work hard</h1>
+  <p>We design and build websites for growing companies.</p>
```

The CSS change was requested. The new headline and the missing cities were not, and the cities may be why the page ranks for local searches. The same happens to `<title>` tags and meta descriptions during a refactor, and a skimmed diff lets it through.

The same prompt against the CMS-backed component can only produce this:

```diff title="src/components/Hero.astro"
-<section class="hero">
+<section class="hero hero--compact">
```

There is no sentence in the file to rewrite. Keep SEO titles and descriptions in CMS fields for the same reason (Diggama's editor can score them as editors type, with the [SEO scoring](https://docs.diggama.com/blueprints) blueprint setting). A rule in your agent's instructions file (`AGENTS.md`, or `CLAUDE.md` for Claude Code) such as "never hardcode user-facing copy" helps, but the model reads that file as context and nothing enforces it. Anthropic says so plainly: its documentation describes `CLAUDE.md` as [context, not enforced configuration](https://code.claude.com/docs/en/memory), and recommends [hooks](https://code.claude.com/docs/en/best-practices) for anything that must happen every time. Removing the copy from the code is the stronger guarantee, whichever agent you use.

## Why do AI-built sites have no drafts, review or scheduling?

Because a Git repository has commits and branches, not editorial states. A post file is either on the main branch and deployed, or it is not. Drafts, review and scheduled publishing all have to be built on top, and generated code rarely builds them.

The usual attempt is a flag in the frontmatter:

```markdown title="src/content/blog/new-pricing.md"
---
title: "Our new pricing for 2027"
excerpt: "Fixed-price packages, what they include, and what changes the quote."
publishDate: 2026-11-02
draft: true
---

From November 2, every website project starts with a fixed-price discovery week.
```

Three problems hide here. `draft: true` is flipped by whoever has Git access, not by the author. A future `publishDate` does nothing on a static site until a build runs after that date, so someone has to deploy on November 2. And "review" means reading Markdown in a pull request, which is not what the page will look like.

In Diggama, publication is a property of every record, set by [`published_at`](https://docs.diggama.com/publishing):

| State | `published_at` | Seen by the production (`view`) token | Seen by a `preview` token |
|---|---|---|---|
| Draft | `null` | No | Yes |
| Scheduled | A future date | No | Yes |
| Published | Now or in the past | Yes | Yes |

The editor clicks **Schedule**, picks November 2 at 9:00, and closes the tab. The production build only ever asks for published records:

```bash
curl -g "https://api.diggama.com/v2/resources/posts?filter[published]=true&sort=-published_at" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer $DIGGAMA_TOKEN"
```

The `-g` flag stops curl from treating the square brackets as a glob. When the scheduled date passes, Diggama fires a **Resource Updated** event within about five minutes, and a Workflow with a **Send webhook** action calls your Cloudflare deploy hook to rebuild the site. The [tutorial's step 5](/guides/build-a-website-with-ai/) sets that up. For review, a preview build uses a `preview` token and shows drafts on a separate URL, so the reviewer sees the real page.

## Why don't blog and SEO pages scale as files?

Because every new page becomes a code change. Ten posts in files are fine. At a hundred, editors cannot search or filter them, each file has slightly different fields depending on the session that wrote it, and every new post is a pull request against the codebase.

Generated sites often go one step further and keep posts in a TypeScript array that the agent appends to:

```ts title="src/data/posts.ts"
// The agent appends one object per post. The file grows with the blog.
export const posts = [
  {
    slug: 'how-we-price-websites',
    title: 'How we price websites',
    date: '2026-08-14',
    excerpt: 'Fixed scope, fixed price, and what changes the quote.',
    body: '<p>Most of our projects start with a one-week discovery phase.</p>',
  },
  {
    slug: 'why-we-moved-to-astro',
    title: 'Why we moved our client sites to Astro',
    date: '2026-09-02',
    excerpt: 'Static HTML by default, and less JavaScript to maintain.',
    body: '<p>We rebuilt three client sites this summer and measured the difference.</p>',
  },
];
```

With the posts in a `posts` blueprint, the code is one template and stays the same size whether there are 2 posts or 2,000:

```astro title="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';

// One HTML file per published post, generated at build time.
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;
---

<Base title={title} description={excerpt} image={cover} type="article" publishedAt={post.published_at}>
  <article>
    <h1>{title}</h1>
    {cover && <img src={cover} alt="" width="1200" height="630" />}
    <RichText html={content} />
  </article>
</Base>
```

One trap to check in any generated data layer: Diggama list endpoints return 25 records by default and at most 100 per request ([pagination](https://docs.diggama.com/pagination)). A helper that does not loop over pages ships a blog that silently stops at 25 posts. The `cms.posts()` helper in the tutorials reads every page.

## How do you handle translations on an AI-built website?

Without a CMS, translations end up in a dictionary file that drifts out of sync with the pages. With one, each record holds a copy per language and the build asks for the language it needs. In Diggama that is a blueprint's [variants](https://docs.diggama.com/blueprints) and a `variant` query parameter on every endpoint.

The dictionary an agent usually writes:

```ts title="src/i18n/ui.ts"
// English is the reference. Any missing French key falls back to English.
const ui: Record<'en' | 'fr', Record<string, string>> = {
  en: {
    'hero.title': 'Websites that work as hard as you do',
    'hero.subtitle': 'Northwind Studio designs and builds websites for growing companies in Lyon and Paris.',
  },
  fr: {
    'hero.title': 'Des sites qui travaillent autant que vous',
  },
};

export function t(lang: 'en' | 'fr', key: string): string {
  return ui[lang][key] ?? ui.en[key] ?? key;
}
```

The French subtitle was never written, so the French homepage shows English and nothing fails. Translators also need Git access to fix it. With `en` and `fr` variants declared on the `homepage` blueprint, editors get one tab per language, and the build fetches each one explicitly:

```ts title="src/lib/localized.ts"
// Reads the homepage in one language. Variants are declared on the
// homepage blueprint in Diggama: en (default) and fr.
import { DIGGAMA_API_URL, DIGGAMA_TOKEN } from 'astro:env/server';

export const LOCALES = ['en', 'fr'] as const;
export type Locale = (typeof LOCALES)[number];

export type LocalizedHomepage = {
  'hero-title': string | null;
  'hero-subtitle': string | null;
  'hero-image': string | null;
  intro: string | null;
};

export async function homepageIn(locale: Locale): Promise<LocalizedHomepage> {
  const url = new URL(`${DIGGAMA_API_URL}/resources/homepage`);
  url.searchParams.set('variant', locale);

  if (!DIGGAMA_TOKEN) throw new Error('Diggama token is missing: set DIGGAMA_TOKEN');

  const res = await fetch(url, {
    headers: { Accept: 'application/json', Authorization: `Bearer ${DIGGAMA_TOKEN}` },
  });
  if (!res.ok) throw new Error(`Diggama ${res.status} on ${url.pathname}${url.search}`);

  const json = (await res.json()) as { data: { attributes: LocalizedHomepage }[] };
  const home = json.data[0]?.attributes;

  // An untranslated variant comes back with empty fields: fail the build
  // rather than publish a French page with no headline.
  if (!home?.['hero-title'] || !home['hero-subtitle']) {
    throw new Error(`Diggama: homepage is not fully translated in "${locale}"`);
  }
  return home;
}
```

```astro title="src/pages/[locale]/index.astro"
---
import { LOCALES, homepageIn, type Locale } from '../../lib/localized';

export function getStaticPaths() {
  return LOCALES.map((locale) => ({ params: { locale } }));
}

const locale = Astro.params.locale as Locale;
const hero = await homepageIn(locale);
---

<html lang={locale}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{hero['hero-title']}</title>
    <meta name="description" content={hero['hero-subtitle']} />
    {LOCALES.map((alt) => (
      <link rel="alternate" hreflang={alt} href={new URL(`/${alt}/`, Astro.site).href} />
    ))}
  </head>
  <body>
    <h1>{hero['hero-title']}</h1>
    <p>{hero['hero-subtitle']}</p>
  </body>
</html>
```

Two details matter. Diggama does not check the variant slug: an unknown slug such as `fr-FR` reads as empty attributes ([resources API](https://docs.diggama.com/resources-api)), which is why the locale list is a constant and the helper fails on empty fields. And every language version must list itself and all the others in its `hreflang` links, or [Google ignores them](https://developers.google.com/search/docs/specialty/international/localized-versions); the loop above does that.

## Where do form submissions go on a vibe-coded site?

Often nowhere. A static site has no server to receive a POST, so a generated contact form frequently ends with a client-side "thank you" and a comment saying the backend comes later. The form looks finished, and messages are lost until someone tests it.

```tsx title="src/components/ContactForm.tsx"
import { useState } from 'react';

export default function ContactForm() {
  const [sent, setSent] = useState(false);

  if (sent) return <p>Thanks! We will get back to you within 24 hours.</p>;

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        // TODO: send to the backend
        setSent(true);
      }}
    >
      <input name="name" placeholder="Your name" required />
      <input name="email" type="email" placeholder="you@company.com" required />
      <textarea name="message" placeholder="Tell us about your project" required />
      <button type="submit">Send</button>
    </form>
  );
}
```

The fix is one small server endpoint that validates the input and stores it. In Diggama, submissions go to `contact-submissions`, a read-only blueprint: records arrive through the API and the team reads them in the dashboard, but nobody can edit them there. The endpoint uses a token that can only create records in that one blueprint.

```ts title="src/pages/api/contact.ts"
// Runs on the Cloudflare Worker at request time. The rest of the site stays static.
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;

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

  const valid =
    name.length > 0 &&
    name.length <= 200 &&
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) &&
    message.length > 0 &&
    message.length <= 5000;
  if (!valid) return redirect('/contact/?error=invalid', 303);

  try {
    // Create-only token, scoped to the contact-submissions blueprint.
    // createDiggama throws if DIGGAMA_FORM_TOKEN is not set.
    const diggama = createDiggama({ baseUrl: DIGGAMA_API_URL, token: DIGGAMA_FORM_TOKEN });
    await diggama.createContactSubmission({ name, email, message });
  } catch (error) {
    // The message carries the status, the error code and the request_id.
    console.error('contact form:', error instanceof DiggamaError ? error.message : error);
    return redirect('/contact/?error=server', 303);
  }

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

To get notified, add a Diggama Workflow on **Resource Created** for `contact-submissions` with a **Send email** action; `{{name}}`, `{{email}}` and `{{message}}` in the email body insert the submitted values (placeholders work in the body, not the subject). The [Astro tutorial](/guides/build-a-website-with-ai/astro/) adds a honeypot field and error messages; the [Next.js tutorial](/guides/build-a-website-with-ai/nextjs/) does the same with a Server Action.

## What happens when the person who prompted the site leaves?

The site keeps running, because it is static files on a host. What leaves is the knowledge: which personal account owns the Cloudflare project, where the API token lives, why the blog helper filters by date, and which prompts produced which pages. The next change then starts with an investigation.

Before, all of that lives in one person's head, their shell history and a `.env` file on their laptop. After, it lives in places the company owns: the repository, a Cloudflare account with more than one member, a CMS project with at least two Administrators, and an `AGENTS.md` that tells the next person, and the next agent session, how the site works:

```markdown title="AGENTS.md"
# Northwind Studio website

Astro site, static pages, one on-demand endpoint (src/pages/api/contact.ts).
Hosted on Cloudflare Workers. Content lives in Diggama, project "Northwind Studio".

## Commands
- `npm run dev`: local server on port 4321
- `npm run build`: production build, must pass before every commit

## Content rules
- Never hardcode user-facing copy, images or lists in components or layouts.
- All content comes from Diggama through the helpers in src/lib (diggama.ts, cms.ts). Pages and components never call fetch() on the API.
- Blueprints: homepage (singular), posts, team-members, contact-submissions (read-only).
- Field keys contain hyphens: read them with bracket notation, e.g. hero['hero-title'].
- To add content, add a field in Diggama first, then the code. Never invent a field key.

## Environment variables
- DIGGAMA_TOKEN: view token, build time only
- DIGGAMA_FORM_TOKEN: create-only token on contact-submissions, runtime only

## Ownership
- Cloudflare, GitHub and Diggama accounts belong to the company, with two admins each.
```

`AGENTS.md` is the one file most agents read without configuration. The exceptions:

| Agent | Reads `AGENTS.md` | What to do |
|---|---|---|
| OpenAI Codex, Cursor, GitHub Copilot (agent mode) | Yes, natively | Nothing |
| Claude Code | Only when there is no `CLAUDE.md` or `CLAUDE.local.md` ([memory docs](https://code.claude.com/docs/en/memory#agents-md)) | If you keep a `CLAUDE.md`, put `@AGENTS.md` at the top of it |
| Gemini CLI | No, it reads `GEMINI.md` by default ([GEMINI.md docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md)) | Set `"context": { "fileName": ["AGENTS.md", "GEMINI.md"] }` in `.gemini/settings.json` (project) or `~/.gemini/settings.json` |

In Diggama, give editors the **Editor** role: they write, publish and upload images, but cannot change blueprints, workflows or tokens. Our [AGENTS.md template for websites](/guides/agents-md-template-for-websites/) covers the full file.

## What does "content as data" mean?

Content as data means storing copy, images and posts as typed records with named fields, separately from the code that renders them, and reading them through an API. The code decides how a post looks; the record decides what it says. Neither has to change for the other to change.

A Northwind Studio post, as Diggama's API returns it:

```json
{
  "id": "gKqv6dx6pBk9",
  "type": "resource",
  "published_at": "2026-09-02T09:00:00+00:00",
  "created_at": "2026-08-30T14:12:00+00:00",
  "updated_at": "2026-09-02T09:00:00+00:00",
  "attributes": {
    "title": "Why we moved our client sites to Astro",
    "slug": "why-we-moved-to-astro",
    "excerpt": "Static HTML by default, and less JavaScript to maintain.",
    "cover": "https://cdn.diggama.com/images/astro-cover.webp",
    "content": "<p>We rebuilt three client sites this summer and measured the difference.</p>"
  },
  "relationships": {
    "blueprint": {
      "data": { "id": "posts", "type": "blueprint" }
    }
  }
}
```

The field keys (`title`, `slug`, `cover`) are the contract between the two sides. Image fields come back as absolute URLs and rich text as an HTML string ([blueprints](https://docs.diggama.com/blueprints)), so the template needs no CMS-specific library. It also helps the agent: one typed shape is easier to work with than 40 components each holding their own sentences.

## What does a headless CMS add that Git and an AI agent don't?

A headless CMS adds the editorial layer that a visual builder like Webflow bundled and a code repository lacks: a place for non-developers to edit, states for drafts and schedules, roles, and an inbox for what visitors submit. The agent keeps owning the code.

| Need | Git and an AI agent alone | Git and an AI agent with a headless CMS |
|---|---|---|
| Fix a typo | Prompt, diff, commit, build | Edit a field, save, automatic rebuild |
| Drafts and review | Branches and pull requests | Draft state, preview token, preview URL |
| Scheduled publishing | Someone deploys on the day | Schedule button, webhook rebuild |
| Permissions | Repository access, all or nothing | Editor, Developer, Administrator roles |
| Translations | Dictionary files | Variants per record, `?variant=fr` |
| Form submissions | Needs a separate service | Read-only blueprint, email notification |
| The agent writing content | Edits files directly | MCP tools limited by the connection, dry runs |

On the last row: with Diggama's MCP server, your agent reads the real blueprints before writing code, and a connection with only `view` is offered no write tools at all. Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot all accept a remote MCP server with a Bearer token; the [MCP guide](/guides/headless-cms-mcp-server/) has the configuration for each. To compare CMS options, see our [headless CMS comparison](/guides/best-headless-cms-2026/).

## When do you not need a CMS?

You do not need a CMS when the only person who will change the content is a developer who is comfortable in Git, or when the content almost never changes. Adding one in those cases is extra moving parts for no benefit.

Concretely, skip it for:

- **A single landing page** that changes a few times a year. Hardcoded copy and a redeploy are cheaper than a content model.
- **A developer's personal site or blog.** Markdown in the repository works well, and [Astro content collections](https://docs.astro.build/en/guides/content-collections/) give those files a schema and type checks.
- **Documentation edited by engineers** through pull requests, where review in Git is the process you want.
- **A prototype or a campaign page** that will be deleted in a month.

A quick test: count the people who will change the words. If the answer is one, and that person reads diffs, use files. If the answer is "marketing", plan the CMS from the start. And if nobody will run an AI coding agent after launch, a visual builder may fit better; our [Webflow vs Framer vs AI coding comparison](/guides/webflow-vs-framer-vs-ai-coding/) sets out when.

## How do you add a CMS to an existing AI-built site in 30 minutes?

Have your agent extract the hardcoded copy into JSON, create the blueprints, import the records with Diggama's bulk create endpoint, then have the agent swap each string for an API call and prove the pages did not change. For a homepage, a blog and a team page, that is about 30 minutes of hands-on work.

### Step 1: snapshot the current build (2 minutes)

Keep today's output to prove the migration changed nothing visible (with the Cloudflare adapter, Astro writes pages to `dist/client`).

```bash
npm run build
cp -r dist dist-before
```

### Step 2: let your agent inventory the copy and write the JSON (8 minutes)

Paste this prompt into Claude Code, Codex, Cursor or whichever agent built the site. If your agent has a read-only planning mode, use it for the inventory (in Claude Code, `claude --permission-mode plan`), then let it write the files.

```prompt
Read every page in src/pages, every component in src/components and every file in src/data. List each piece of user-facing text, image and repeated item that a non-developer would want to change.

Then write two kinds of files and change nothing else:

1. diggama/blueprints.json in Diggama's JSON import format (read https://docs.diggama.com/json-import first): an array of blueprints with name, icon, type and fields, required fields marked with "rules": ["required"], and no "data" key. Use these blueprints: "Homepage" (singular_resource) with Hero title (text), Hero subtitle (text), Hero image (image), Intro (rich-text); "Posts" (resource) with Title (text, required), Slug (slug, source "title"), Excerpt (text), Cover (image), Content (rich-text); "Team members" (resource) with Name (text, required), Role (text), Photo (image), Bio (rich-text); "Contact submissions" (readonly_resource) with Name (text), Email (email), Message (text).

2. One file per blueprint in diggama/content/, named after the blueprint slug (homepage.json, posts.json, team-members.json): a JSON array of attribute objects using the API field keys (hero-title, hero-subtitle, intro, title, slug, excerpt, content, name, role, bio). Copy the text exactly as it appears on the site, convert paragraphs to HTML for rich-text fields, and leave out every image field. When a post shows a date, add a published_at key with that date in ISO 8601 with an offset (for example 2026-08-14T09:00:00+02:00). homepage.json holds exactly one object.

Finish with a table of anything you could not map.
```

Images are left out on purpose: Diggama's API and MCP server do not upload files, so upload the few images of a small site in the dashboard.

### Step 3: create the blueprints and a temporary token (5 minutes)

In the Diggama dashboard, open **Blueprints**, click **Import JSON** and upload `diggama/blueprints.json` ([JSON import](https://docs.diggama.com/json-import)). The import runs in the background and notifies you when it is done; if it fails, nothing is created. Field keys are derived from field names, so "Hero title" becomes `hero-title`, matching the content files. Then, under **Configuration › API Tokens**, create an import token with `view`, `create` and `update` on `homepage`, `posts` and `team-members` ([authentication](https://docs.diggama.com/authentication)).

### Step 4: import the records with bulk create (5 minutes)

This script sends each content file to `POST /v2/resources/{blueprint}/bulk` in batches of 100, the endpoint's limit ([bulk operations](https://docs.diggama.com/bulk-operations)), and updates the homepage's single record in place, since a singular blueprint creates its one record itself and refuses a create with `422`. Records arrive as drafts. With `--publish`, a bulk update then gives each one its own `published_at`: bulk create only accepts one date for the whole batch, which would give every old post today's date.

Run it before you add the rebuild webhook. Records created through the API fire **Resource Created** events just as dashboard edits do, so 150 imported posts would mean 150 rebuild requests.

```js title="scripts/diggama-import.mjs"
// One-off import of diggama/content/*.json into Diggama.
// Usage: DIGGAMA_IMPORT_TOKEN=... node scripts/diggama-import.mjs [--publish]
// DIGGAMA_API_URL is optional, for example to point at a local mock.
// Without --publish every record stays a draft. With it, each record is
// published at its own published_at (the original post date), or now.
import { readFile, readdir } from 'node:fs/promises';

const API = process.env.DIGGAMA_API_URL ?? 'https://api.diggama.com/v2';
const SINGULAR = new Set(['homepage']);
const token = process.env.DIGGAMA_IMPORT_TOKEN;
const publish = process.argv.includes('--publish');

if (!token) {
  console.error('Set DIGGAMA_IMPORT_TOKEN');
  process.exit(1);
}

async function api(method, path, body) {
  const res = await fetch(`${API}/${path}`, {
    method,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => null);
  // 207 means some items of a bulk create failed: handled by the caller.
  if (!res.ok && res.status !== 207) {
    throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(json?.error ?? json)}`);
  }
  return json;
}

// Prints each failed item with its position in the content file.
function reportFailures(blueprint, positionOf, failed) {
  for (const failure of failed) {
    const detail = failure.errors ?? failure.error;
    console.error(`  ${blueprint} item ${positionOf(failure.index)}: ${JSON.stringify(detail)}`);
  }
}

const dir = new URL('../diggama/content/', import.meta.url);
const files = (await readdir(dir)).filter((file) => file.endsWith('.json'));
const now = new Date().toISOString();

for (const file of files) {
  const blueprint = file.replace(/\.json$/, '');
  const records = JSON.parse(await readFile(new URL(file, dir), 'utf8'));

  if (SINGULAR.has(blueprint)) {
    // A singular blueprint already has its one record: update it.
    const list = await api('GET', `resources/${blueprint}`);
    const id = list.data[0]?.id;
    if (!id) throw new Error(`${blueprint}: no record found`);
    await api('PATCH', `resources/${blueprint}/${id}`, { attributes: records[0] });
    console.log(`${blueprint}: updated ${id}`);
    continue;
  }

  for (let start = 0; start < records.length; start += 100) {
    const batch = records.slice(start, start + 100);

    // published_at is not a field: keep it aside and create drafts.
    const items = batch.map(({ published_at, ...attributes }) => ({ attributes }));
    const result = await api('POST', `resources/${blueprint}/bulk`, { resources: items });
    console.log(`${blueprint}: ${result.meta.created} created, ${result.meta.failed} failed`);
    reportFailures(blueprint, (index) => start + index, result.data.failed);

    if (!publish || result.data.created.length === 0) continue;

    // Bulk create sets one date for the whole batch. Bulk update takes one
    // per record, so each post keeps its original publication date.
    const updates = result.data.created.map(({ id, index }) => ({
      id,
      published_at: batch[index].published_at ?? now,
    }));
    const published = await api('PATCH', `resources/${blueprint}/bulk`, { updates });
    console.log(`${blueprint}: ${published.meta.updated} published, ${published.meta.failed} failed`);
    const created = result.data.created;
    reportFailures(blueprint, (index) => start + created[index].index, published.data.failed);
  }
}
```

```bash
DIGGAMA_IMPORT_TOKEN=your-import-token node scripts/diggama-import.mjs --publish
```

Each item is validated like a single create, and failures are reported by their position in the content file without blocking the rest. A `401` means the token value is wrong or expired; a `403 FORBIDDEN` means it lacks an ability on that blueprint. Fix the file and re-run only the failed items, or you will create duplicates.

Then upload the images in the dashboard and take the write abilities off the token. Editing a token changes its abilities and keeps its value ([authentication](https://docs.diggama.com/authentication)), so with only `view` left it becomes the build token for step 5. That matters on Starter, which includes one API key; on Growth or Enterprise you can revoke it and create a dedicated `view` token instead. If the Diggama MCP server is connected with write access, your agent can do this step itself with `bulk_create_resources` (25 records per call, always created as drafts).

### Step 5: swap the strings for API calls and prove nothing changed (10 minutes)

Put the `view` token in `.env` as `DIGGAMA_TOKEN`, and add a rule against hardcoded copy to `AGENTS.md` (or `CLAUDE.md`). Then give your agent this prompt:

```prompt
Add src/lib/diggama.ts and src/lib/cms.ts as described in https://diggama.com/guides/build-a-website-with-ai/astro/ (one client, pagination over every page, published records only, errors that include the status and request_id), and declare DIGGAMA_API_URL and DIGGAMA_TOKEN in the astro:env schema of astro.config.mjs as that tutorial does, keeping prerenderEnvironment: 'node' on the Cloudflare adapter. Then replace every hardcoded string that was moved to diggama/content with the matching Diggama field, one page at a time, deleting src/data files once nothing imports them. Do not change markup or classes.

When done, run npm run build. For every HTML file in dist-before, compare its visible text with the same file in dist and list each difference. Images are expected to differ in URL only. Fix anything else.
```

The visible-text comparison is the check that matters: it turns "looks done" into a pass or fail the agent can iterate on, which is what [Anthropic recommends](https://code.claude.com/docs/en/best-practices) for unattended work; the advice applies to any agent. Before the first deploy, add `DIGGAMA_TOKEN` to the Cloudflare build variables, or the CI build fails with "Diggama token is missing" while the local build passes. Finish with the rebuild webhook and editor accounts from [steps 5 and 6 of the AI website guide](/guides/build-a-website-with-ai/).

Diggama has a three-month free trial with no credit card ([sign up](https://manage.diggama.com/register)); Starter then costs 29.90 EUR a month for 3 users and 5 custom blueprints, with MCP and Workflows on every plan ([pricing](/pricing/)). To sketch a model first, use the [content model generator](/tools/content-model-generator/).

## Where do you go from here?

Starting from scratch: follow the [guide to building a website with AI](/guides/build-a-website-with-ai/), then the [Astro](/guides/build-a-website-with-ai/astro/) or [Next.js](/guides/build-a-website-with-ai/nextjs/) tutorial, which use the same Northwind Studio model. Still on Webflow: the [migration guide](/guides/migrate-from-webflow/) covers collections and redirects. Either way, write an [AGENTS.md](/guides/agents-md-template-for-websites/) before the next prompt. And for where AI-built sites and content tools are heading next, see [the web and CMS trends for 2027](/guides/web-and-cms-trends-2027/).

## FAQ

### Do I need a CMS if Claude Code, Codex or Cursor builds my website?

You need one when people other than the developer change the content. AI coding agents write copy straight into components, so without a CMS every edit goes through a terminal, a diff and a deploy. If you are the only person who will ever touch the text and you are comfortable with Git, Markdown files in the repository are enough.

### Can't the marketing team just ask the AI agent to change the copy?

They can, but each change still needs someone to run the agent, review the diff, commit and wait for a build. It also gives an agent write access to code for what should be a text edit, and a request about wording can come back with layout changes. A CMS turns that into a form field and a Publish button.

### Is Markdown in Git good enough instead of a CMS?

For a developer's own blog or documentation edited through pull requests, yes. Astro content collections, for example, give Markdown files a schema and type checking. It stops working when editors who do not use Git need drafts, scheduled publishing, image uploads or translations.

### Will a headless CMS make my site slower?

Not if the site fetches content at build time. The pages are still static HTML served from a CDN, the API token never reaches the browser, and a CMS outage cannot take the live site down. The trade-off is a rebuild after each publish, which a webhook automates.

### How long does it take to add a CMS to a site an AI agent already built?

For a small marketing site with a homepage, a blog and a team page, about 30 minutes of work: your agent inventories the hardcoded copy and writes it as JSON, a script imports it, and the agent swaps the strings for API calls. Images are uploaded by hand in the CMS dashboard, and large sites take longer mainly because there is more copy to review.

### Can an AI coding agent still write content once it lives in a CMS?

Yes, through an MCP server. Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot can all connect to Diggama's, which lets the agent list content types, read records and, if the connection allows it, create drafts, with a dry run that shows the result first. A read-only connection is offered no write tools at all.
