
Factories: Clean Test Data Without the Pain
Tired of brittle test setups and copy-pasted builders? Learn how to manage unit test data with maintainable, flexible factories. This guide walks...
A battle-tested AI agent prompt that migrates your blog from Contentful to PayloadCMS — no 47-step tutorial required.
You've seen those migration guides. Forty-seven steps, two hundred screenshots, a dozen "now open your terminal and run..." instructions. By the time you finish reading, half the APIs have changed and you're debugging a guide instead of building your product.
I just migrated my portfolio blog from Contentful to PayloadCMS. Twenty-five commits, eighty-four files, a complete Rich Text AST transformation, Neon database branching for preview deploys — the works. And the thing that made it possible in a single sitting? Claude Code.
Here's what I realized halfway through: the best "migration guide" isn't a tutorial anymore. It's a well-specified prompt. Not vague — precise. Not a walkthrough — instructions that an agent can execute.
So instead of writing yet another step-by-step guide that'll be outdated by next quarter, I'm sharing the exact prompt — refined from a real migration — that you can paste into your AI coding agent to do this migration yourself. Think of it as documentation that runs.
Fair warning: this isn't a "hello world" prompt. It's dense, it's specific, and that's the point. The more precise you are with your agent, the less back-and-forth you'll need.
Quick context so the prompt makes sense — I'm not here to sell you on Payload, but here's why it was the right move for me:
That last point is the killer feature. Going from "my CMS is a separate SaaS with webhooks and revalidation triggers" to "my CMS is just another route in my Next.js app" simplified everything.
Below is the full prompt. It's parameterized — replace the `[bracketed placeholders]` with your own values. Copy it, paste it into your AI coding agent (Claude Code, Cursor, Copilot, whatever you use), and let it work.
I've split it into three sections: setup, AST transformation (the hard part), and migration script.
# Migrate my blog from Contentful to PayloadCMS v3
## Context
I have a Next.js (App Router) project with blog content currently in Contentful.
I want to migrate to PayloadCMS v3, self-hosted in the same Next.js app.
My stack:
- Database: [postgres | sqlite | mongodb] (I used PostgreSQL with @payloadcms/db-postgres)
- Storage: [vercel-blob | s3 | local] (I used Vercel Blob with @payloadcms/storage-vercel-blob)
- Package manager: [pnpm | npm | yarn]
---
## Section A: PayloadCMS Setup
### 1. Install Payload v3
Install these packages:
- `payload` (core)
- `@payloadcms/next` (Next.js integration)
- `@payloadcms/richtext-lexical` (Lexical editor)
- `@payloadcms/db-[your-adapter]` (database adapter)
- `@payloadcms/storage-[your-provider]` (file storage — optional if using local disk)
- `sharp` (image processing)
- `lexical` (Lexical type definitions)
### 2. Create `payload.config.ts`
Configure Payload with:
- PostgreSQL adapter (or your chosen DB) using `DATABASE_URL` env var
- Lexical editor as the default editor
- Storage plugin for media uploads using `BLOB_READ_WRITE_TOKEN` env var (if using Vercel Blob)
- `sharp` for image processing
- Collections: Users, Posts, Media (defined below)
### 3. Define collections
[Adapt this prompt together with your agent, give it your contentful models as input!]
[The collections I made based on my contentful schema:]
**Users** (`src/collections/Users.ts`):
- slug: `users`
- auth: true (enables Payload authentication)
- fields: `name` (text)
- Use as admin title: `email`
**Media** (`src/collections/Media.ts`):
- slug: `media`
- read access: public (`() => true`)
- upload config:
- mimeTypes: `['image/*']`
- imageSizes: `thumbnail` (400×300), `card` (768×1024), both with `position: 'centre'`
- focalPoint: true
- crop: true
- fields: `alt` (text, required)
**Posts** (`src/collections/Posts.ts`):
- slug: `posts`
- read access: public
- versions with drafts enabled
- admin: useAsTitle `title`, default columns: title, slug, publicationDate, _status
- fields:
- `title` (text, required)
- `slug` (text, required, unique, indexed, kebab-case validation)
- `description` (textarea, required, maxLength 150)
- `publicationDate` (date, required)
- `featuredImage` (upload, relationTo `media`)
- `tags` (text, hasMany: true)
- `canonicalUrl` (text, optional, URL validation)
- `content` (richText, required, Lexical editor with BlocksFeature containing CodeBlock)
**CodeBlock** (`src/collections/blocks/CodeBlock.ts`):
- slug: `codeBlock`
- fields:
- `language` (text, required — e.g., typescript, python, mermaid)
- `code` (code field, required)
### 4. Route groups
Separate the Payload admin panel from your site layout using Next.js route groups:
- Move site pages into `src/app/(site)/`
- Put Payload admin at `src/app/(payload)/admin/`
This prevents the admin panel from inheriting your site's Header, Footer,
i18n providers, and global CSS.
### 5. Next.js config
- Wrap your `next.config.js` with `withPayload` from `@payloadcms/next/withPayload`
- Add your storage provider's domain to `images.remotePatterns`
---
## Section B: Contentful Rich Text → Lexical AST Transformation
This is the hardest part of the migration. Contentful's Rich Text uses its own AST
format. Payload uses Lexical's AST. You need a pure function that transforms one to
the other.
### The transformation function
Create `src/lib/contentful-to-lexical.ts` — a pure, deterministic transformer
with zero side effects. It takes a `warn` callback for unknown node types
instead of using `console.warn` directly (makes it testable).
### Node type mapping table
| Contentful `nodeType` | Lexical `type` | Extra fields |
| ------------------------------- | ---------------- | ---------------------------------------------------------------------- |
| `text` | `text` | `text: node.value ?? ''`, `format: computeFormat(node.marks ?? [])` |
| `paragraph` | `paragraph` | `children: transformChildren(node.content)` |
| `heading-1` through `heading-6` | `heading` | `tag: 'h1'` through `'h6'` |
| `ordered-list` | `list` | `listType: 'number'` |
| `unordered-list` | `list` | `listType: 'bullet'` |
| `list-item` | `listitem` | `children: transformChildren(node.content)` |
| `blockquote` | `quote` | `children: transformChildren(node.content)` |
| `hr` | `horizontalrule` | leaf node — only `type` and `version`, no children/direction/format |
| `hyperlink` | `link` | `fields: { url: node.data.uri }` |
| `entry-hyperlink` | `link` | `fields: { url: node.data.uri }` |
| `asset-hyperlink` | `link` | `fields: { url: node.data.uri }` |
| `embedded-entry-block` | resolve from linked entries map — see below |
### Text format bitmask
Contentful marks (bold, italic, etc.) map to a Lexical format bitmask:
| Mark | Bitmask value |
| --------------- | ------------- |
| `bold` | 1 |
| `italic` | 2 |
| `strikethrough` | 4 |
| `underline` | 8 |
| `code` | 16 |
| `subscript` | 32 |
| `superscript` | 64 |
Combine with bitwise OR. A bold+italic node has format `3` (1 | 2).
### Required fields on every Lexical element node
Every element node (paragraph, heading, list, quote, link, etc.) MUST include:
- `type` — the node type string
- `version: 1` — always 1 (except `block` nodes which use version 2)
- `direction: null`
- `format: ''`
- `indent: 0`
- `children` — array of child nodes
**Exceptions:** `text` nodes only need `type`, `version: 1`, `text`, and `format` (the bitmask number). `horizontalrule` is a leaf node — it only needs `type` and `version: 1`.
### Handling embedded entries (code blocks)
Contentful stores embedded entries as references. To resolve them:
1. Build a `LinkedEntriesMap` (Map<string, LinkedEntry>) from the Contentful
response's `includes.Entry` array
2. When you hit an `embedded-entry-block` node, look up the entry by
`node.data.target.sys.id` in the map
3. If the linked entry's `contentTypeId` is `codeBlock`, emit a Lexical `block` node:
```typescript
{
type: 'block',
fields: {
id: randomBytes(12).toString('hex'),
blockName: '',
blockType: 'codeBlock',
language: (linkedEntry.fields['programmingLanguage'] as string) ?? '',
code: (linkedEntry.fields['code'] as string) ?? '',
},
children: [],
direction: null,
format: '',
indent: 0,
version: 2, // block nodes use version 2, not 1
}
```
4. For unknown content types, call `warn()` and return null
### The root wrapper
The top-level output must be a Lexical `root` node:
```typescript
{
root: {
type: 'root',
children: [...transformed top-level nodes],
direction: null,
format: '',
indent: 0,
version: 1,
}
}
```
### Link fallback behavior
If a `hyperlink`/`entry-hyperlink`/`asset-hyperlink` node is missing a `uri`,
warn and return the first child node as plain text if there's exactly one child.
If there are zero or multiple children, return null (drop the node).
---
## Section C: Migration Script
Create an idempotent migration script at `scripts/migrate-contentful-to-payload.ts`.
### Environment variables
- `DATABASE_URL` — PostgreSQL connection string (or your DB)
- `PAYLOAD_SECRET` — Payload secret key
- `CONTENTFUL_SPACE_ID` — your Contentful space ID
- `CONTENTFUL_ACCESS_TOKEN` — Content Delivery API token
- `CONTENTFUL_ENVIRONMENT` — Contentful environment (default: `master`)
- `CONTENTFUL_CONTENT_TYPE` — content type to migrate (default: `blogPost`)
- `CONTENTFUL_USE_PREVIEW` — set to `true` to include drafts
- `CONTENTFUL_PREVIEW_ACCESS_TOKEN` — Preview API token (required when using preview)
### Migration steps
1. **Connect** to both Contentful (via `contentful` SDK) and Payload (via `getPayload`)
2. **Fetch all entries** for the content type with `include: 10` (resolves nested references)
3. **Build the linked entries map** from `response.includes.Entry`
4. **For each post:**
a. **Idempotency check** — query Payload for existing post with the same slug. Skip if found.
b. **Upload featured image** — download from Contentful's CDN, upload to Payload's media collection
c. **Transform rich text** — run the Contentful document through `transformRichText()`
d. **Create post** in Payload with `draft: true` so you can review before publishing
5. **Print summary** — total, success, skipped, errors
### Important details
- Contentful image URLs may start with `//` — prepend `https:` if so
- Use the image's `description` field as alt text, falling back to `title`
- Handle unresolved Contentful links (missing `.fields`) gracefully — warn and skip
- Set `_status: 'published'` in the post data but pass `draft: true` to `payload.create()`
so posts are created as reviewable drafts
### Running the script
```bash
DATABASE_URL="..." PAYLOAD_SECRET="..." \
CONTENTFUL_SPACE_ID="..." CONTENTFUL_ACCESS_TOKEN="..." \
npx ts-node scripts/migrate-contentful-to-payload.ts
```
Or build an admin UI page at `/admin/migrate` using Payload's server actions
for a more user-friendly experience.Step 1: Replace all `[bracketed placeholders]` with your actual values. At minimum: your database adapter, storage provider, and package manager.
Step 2: If your Contentful content model differs from mine (different field names, extra content types), update the field mappings in Section B and C. The structure stays the same — just swap the field names.
Step 3: Paste the whole thing into your AI coding agent. I used Claude Code, but any capable agent should work.
Step 4: Let the agent work. It'll ask you clarifying questions if your project structure differs from what it expects. Answer them. This is collaboration, not magic.
Step 5: Review the generated code, run the migration against a test database first, and verify your content looks right before pointing it at production.
I'll be honest — when I started this migration, I did try following guides first. They were helpful for understanding the concepts, but the moment I needed to actually wire things up in my specific project, I was on my own.
A prompt like this works better because it's declarative. It says what the system should look like, not how to click through a UI. Your AI agent handles the how — adapting to your project structure, your existing config, your package manager.
And here's the best part: when Payload v4 comes out, I update the prompt — not a 47-page guide. Change a few version numbers, update the breaking changes, and the prompt works again. Try doing that with a screenshot-heavy tutorial.
This prompt was battle-tested on a real migration — my own portfolio, running in production right now (you're looking at it!). It's not theoretical. The AST mapping table, the format bitmask values, the idempotency logic — all of it came from actually building and debugging the thing.
Will it work perfectly for your project on the first try? Probably not. Your Contentful model might have different field names. You might be using MongoDB instead of Postgres. You might have content types I didn't cover. That's fine. The prompt is a starting point. Adapt the placeholders, add your custom content types, and let your agent figure out the rest.
The era of static migration guides is ending. The documentation of the future is executable.

Tired of brittle test setups and copy-pasted builders? Learn how to manage unit test data with maintainable, flexible factories. This guide walks...

Creating a bordered box around your own knowledge is ruining your potential. Screw job titles, we engineers are here to help our customers by...

The DRY principle guides software engineers, but some cases benefit from repetition. This post explores when DRY should be broken.