Migrate to Prismic with AI
Migrate from Contentstack to Prismic
Contentstack content is already structured JSON, so the data itself moves easily. The real work is the model. Contentstack pages are built from content types with Modular Blocks that can nest several levels deep, while a Prismic page is a flat, ordered list of slices. Most of this guide is about making that translation deliberately.
An AI agent handles the repetitive parts: it audits the export, proposes the slice library, generates the models and components, writes the migration script, and checks the result through the Prismic MCP.
For enterprise stacks, start with a proof of concept on two templates, usually a landing page and an article, before committing to the full model. Prismic's Enterprise Solutions Architecture team can run it with you.
Before you start
- The stack API key and a management token (for export) or a delivery token and environment name (for the Delivery API)
- The Contentstack CLI, installed with npm install -g @contentstack/cli
- An empty Prismic repository with the same locales as the stack, and a write API token
- A Next.js project with the Prismic CLI, and Node 20 or later
- Claude Code (or Cursor) with the Prismic MCP connected to the new repository
1. Export the stack
A full export gives the agent the schemas, global fields, entries for every locale and the assets in one folder it can read offline. It is also your rollback copy.
csdx auth:login
csdx cm:stacks:export --stack-api-key <API_KEY> --data-dir ./exportRead the Contentstack export in ./export. For every content type and global field, list its fields, its Modular Blocks with their nesting depth, and its references. Count entries per content type and locale, flag Modular Blocks nested more than one level deep, and flag JSON RTE fields that embed entries or assets. Write the result to migration/audit.md and do not change anything.
2. Map Contentstack to Prismic
Everything you can build with content types and Modular Blocks, you can build with custom types and slices. The mapping below covers most stacks.
- Page-like content type → repeatable custom type with a slice zone
- Single content type → single (non-repeatable) custom type
- Each block in a Modular Blocks field → shared slice, with variations for blocks that differ only slightly
- Nested Modular Blocks → promote child blocks to sibling slices, or turn repeated children into a group inside the parent slice
- Global field → shared slice when it is a page section, otherwise the same fields repeated in each custom type
- Group field (multiple) → repeatable group
- JSON RTE or HTML RTE → rich text field
- Reference → content relationship; file → image or link to media
- Taxonomies and tags → a taxonomy custom type with content relationships, or Prismic tags
- Environments → one published state, with releases and previews for staged content
Slices never nest, and that is deliberate: flat pages stay predictable for marketers and for AI agents producing pages at volume. Flattening is a product decision as much as a technical one, so review the proposed slice library with the client's marketing team before you build it.
Read migration/audit.md and propose a Prismic content model. Map each content type to a custom type and each Modular Block to a shared slice, merging near-identical blocks into variations. For every nested Modular Block, propose how to flatten it (sibling slices or a group inside the parent slice) and explain the trade-off. Write a mapping table to migration/mapping.md and wait for my approval before creating any model files.
3. Create the models
Generate the slices, custom types and components from the approved mapping, then push the models.
Create every custom type and shared slice in migration/mapping.md in this project, with a component for each slice styled with our existing design tokens and mock content for the slice simulator. Configure the same locales as the Contentstack stack. Show me the list of files before pushing the models.
npx prismic push4. Write the migration script
The script reads entries per content type and locale, converts JSON RTE to HTML with Contentstack's serializer and then to Prismic rich text, maps each Modular Block to a slice, and links translations to their master-language document. The Migration API uploads each asset once and stages everything in a Migration release.
npm install --save-dev @prismicio/client @prismicio/migrate @contentstack/json-rte-serializer tsximport * as prismic from "@prismicio/client";
import { htmlAsRichText } from "@prismicio/migrate";
import { jsonToHtml } from "@contentstack/json-rte-serializer";
const CS = "https://cdn.contentstack.io/v3";
const headers = {
api_key: process.env.CS_API_KEY!,
access_token: process.env.CS_DELIVERY_TOKEN!,
};
const writeClient = prismic.createWriteClient("your-repo", {
writeToken: process.env.PRISMIC_WRITE_TOKEN!,
});
const migration = prismic.createMigration();
// Page through a content type's entries for one locale
async function entries(type: string, locale: string) {
const all = [];
for (let skip = 0; ; skip += 100) {
const url = `${CS}/content_types/${type}/entries?environment=production&locale=${locale}&skip=${skip}&limit=100&include_count=true`;
const { entries, count } = await (await fetch(url, { headers })).json();
all.push(...entries);
if (all.length >= count || entries.length === 0) break;
}
return all;
}
const richText = (json) => htmlAsRichText(jsonToHtml(json)).result;
// One Modular Block becomes one slice
function toSlice(block) {
const [name, value] = Object.entries(block)[0];
switch (name) {
case "hero":
return {
slice_type: "hero",
variation: value.background_image ? "withBackground" : "default",
primary: {
heading: value.title,
body: richText(value.description),
image: value.background_image
? migration.createAsset(value.background_image.url, value.background_image.filename)
: undefined,
},
items: [],
};
// ...one case per block in migration/mapping.md
default:
console.warn(`Unmapped block: ${name}`);
return null;
}
}
const masters = new Map();
for (const locale of ["en-us", "fr-fr"]) {
for (const entry of await entries("landing_page", locale)) {
if (entry.locale !== locale) continue; // skip fallback content
const doc = migration.createDocument(
{
type: "landing_page",
uid: entry.url.replace(/^\//, "").replaceAll("/", "-") || "home",
lang: locale,
tags: [],
data: {
meta_title: entry.seo?.meta_title,
slices: entry.sections.map(toSlice).filter(Boolean),
},
},
entry.title,
{ masterLanguageDocument: masters.get(entry.uid) },
);
if (locale === "en-us") masters.set(entry.uid, doc);
}
}
await writeClient.migrate(migration, {
reporter: (event) => console.log(event.type),
});Field, slice and content type names above are placeholders; they must match the stack and your models. Ask the agent to write the full script from the mapping.
Using migration/mapping.md and the models in this project, write migration/contentstack.ts with the Prismic Migration API from @prismicio/client. Convert JSON RTE with @contentstack/json-rte-serializer and @prismicio/migrate, flatten nested Modular Blocks as agreed in the mapping, upload each asset once with createAsset, and resolve references as content relationships. Create master-locale documents first and link translations with masterLanguageDocument, skipping entries served from a fallback locale. Log every unmapped block, field or embedded RTE item to migration/report.md, and add --type and --limit flags.
5. Dry run, then run it all
Migrate a handful of entries per content type, open them in the Page Builder from the Migration release, and compare them side by side with the live Contentstack pages. Adjust the mapping, delete the test documents, then run the full migration.
npx tsx migration/contentstack.ts --type landing_page --limit 10
npx tsx migration/contentstack.tsUsing the Prismic MCP, list every document in the Migration release and compare the count per custom type and locale with migration/audit.md. List documents with an empty slice zone, translations not linked to a master-language document, broken content relationships, and images still served from Contentstack. Add the findings to migration/report.md.
6. Cut over
Contentstack entries carry their own URL field, so redirects are straightforward: compare each entry URL with what your link resolver produces and add permanent redirects for the differences.
Content teams rarely stop publishing during a migration. On cut-over day, freeze Contentstack, re-run the script for entries updated since the first export by filtering on updated_at, publish the Migration release, then switch traffic and retire the Contentstack webhooks.
Common issues
- Embedded entries and assets in JSON RTE: the serializer needs the entry's _embedded_items to resolve them, so map each to a link, an image or a separate slice
- References pointing to several content types: allow each of them in the content relationship field
- Blocks nested three or more levels deep: usually a sign the section should be redesigned, not translated
- Unlocalized entries returning master-language content: compare entry.locale with the requested locale before creating a translation
- Global fields reused across many types: decide once whether each is a slice or a set of fields, then apply it everywhere