Migrate Strapi Content (batch, MCP-to-MCP)
Batch-migrate records from production-old (Strapi v4 at api-prod.strapi.io) to production (Strapi v5) via the Strapi MCP. Built for the common case where you paste a batch of URLs and want zero further input.
Hard rule: NEVER ask the user anything — no exceptions
This skill runs fully autonomous. Do NOT use AskUserQuestion. Do NOT ask for approval. Do NOT ask to choose replace/append. Do NOT ask to publish. Do NOT pause before overwriting curated production content — the user has already agreed to replace by invoking the skill.
Apply the defaults below and report at the end. The user's input is the URL list — that is the entire contract.
If something truly cannot be decided from defaults + schema inspection (e.g. target content type unclear from URL, or target record can't be located), skip that URL, log it in the report under unmatchedUrls or per-URL error, and continue. Do not stop the batch to ask.
Override is possible only if the user explicitly writes interactive or ask me in the prompt — then (and only then) you may ask.
Mapping references
Authoritative cheatsheet: components-cheatsheet.csv next to this file. One row per old slice / root field, with the v5 target, variant/layout flags, and a short notes field describing the field-mapping rule. Step 6 below is the code-form expansion of those rows — when CSV and Step 6 disagree, the CSV is the source of truth and Step 6 should be updated to match.
Visual reference: a live component library is available at https://website-ui-omega.vercel.app/dev/component-library. When uncertain how a v5 component renders, fetch that page (Playwright or WebFetch) and compare against the v4 source. Prefer a real v5 component over migration.data-sink.
Default mapping heuristic (use this as your mental model)
Most of the content in old slices falls into a handful of shapes. Before checking the detailed table, ask which of these the old slice renders as:
- Columns / grid of cards (each with title + description) →
sections.feature-card-grid- Use when the slice has a repeatable
cards[],features[],items[], orintegrations[]array of tiles. - The optional
sectionfield (utilities.section-header) carries the heading + description above the grid.
- Use when the slice has a repeatable
- Single boxed content block (title + description + optional CTA + optional image position) →
cards.feature-card- Use when the slice is a standalone content panel with one title and one body of text.
- Examples:
slices.section-with-image,slices.text-next-to-image,slices.simple-text-next-to-image,slices.text-next-to-big-image,slices.text-with-image-and-gradient.
- Multi-column feature list with a shared heading (smaller text-only tiles, not full cards) →
sections.two-column-grid- Use when items are short title+description pairs tightly grouped under one heading — no images, no CTAs.
- Items are
elements.how-it-works-item(title + description). The grid'ssectionfield is REQUIRED — always populate it from the slice's intro/title.
- Self-contained heading / section separator →
sections.section-header- Use for standalone label+title+description blocks that introduce a section but don't contain items.
- Background/boxed variants live here too (e.g. interview's "dark + boxed" wrapper).
- Paragraphs of markdown text →
sections.richtext - Hero at the top of a page →
sections.hero(slices OR root-level fields likeuseCaseHero,homeHero,whiteHero,careersHero,featuresHero,communityHero— all PREPENDED to newContent) - CTA banner with title/text/button →
sections.cta-banner(background=dark|dark-inverse; the only "light" variant is achieved viasections.section-headerwith background=light instead) - Quote →
testimonials.quote - Reference to a case study →
cards.case-study-card(with target lookup forcompanyName/title) - Logo / brand grid →
media.brand-logo-grid(logos are uploaded from old CDN, deduped by filename) - Image gallery / slider →
media.image-gallery(images uploaded same way as brand logos) - Newsletter signup →
forms.newsletter(limited — only thehubspotFormrelation is migrated; frontend renders fixed copy) - Disclaimer notice →
sections.disclaimer(title + content; SKIP if the v5 frontend renders it hardcoded for the route) - 3-column stat/issue grid (required heading) →
sections.three-column-grid(items =elements.how-it-works-item;itemStyle: "bordered"for emphasis variants likeslices.issues-header) - Tabbed feature strip (pill tabs, one feature-overview per tab) →
sections.tabbed-feature-overview(each tab requires an image — uploaded via the brand-logo routine; tabs without images get SKIPPED) - Customer reviews carousel →
sections.reviews(title required; reviews relation resolved againstapi::review.reviewby author match; relation left empty for manual follow-up if no matches) - Auto-fetched chronological list (no fields) →
sections.news-list(empty payload — frontend handles fetching)
Everything else → migration.data-sink (if allowed) or SKIP.
The detailed per-slice rules in Step 6 are refinements of this heuristic with the exact field mapping.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
urls |
yes | — | Whitespace/newline-separated list of URLs (any host — only the path matters) |
source_server |
no | production-old |
Strapi MCP server to read from |
target_server |
no | production |
Strapi MCP server to write to |
mode |
no | replace |
replace wipes+rebuilds the target dynamic zone; append adds to existing. Default is replace. |
publish |
no | true |
After successful migration, publish the target record |
media_policy |
no | reuse-existing |
If target record already has cover/logo set, leave them; else upload from old if old has them; else skip |
Everything else (target documentId, category documentIds, schema mapping) is discovered at runtime.
URL → content type mapping
Detect content type from the URL path prefix. The host doesn't matter — strapi.io, website-ui-omega.vercel.app, localhost:3000 all resolve the same.
| URL path pattern | Old endpoint | New endpoint | Target content type | Match by |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | --------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| /user-stories/<slug> | api/case-studies | api/case-studies | case-study | slug |
| /blog/<slug> | api/blog-posts | api/blog-posts | blog-post | slug |
| /news/<slug> | api/news-items | api/news-items | news-item | slug |
| /jobs/<slug> | api/internal-jobs | api/internal-jobs | internal-job | slug |
| /comparators/<slug> or /<slug>-vs-<slug> or /headless-cms/comparison/<slug> | api/comparators | api/cms-comparisons | cms-comparison | slug | (source v4 collection is api/comparators — NOT api/cms-comparisons, which is the v5 target name. The comparison index /headless-cms/comparison itself is a page, see below.) |
| /solutions/<slug> | api/use-cases | api/pages | page (cross-type) | fullPath = /solutions/<slug> |
| /features and /features/<slug> | (CONFIRMED no per-slug v4 source — api/feature is a single-type covering only the /features index; features/single-features/product-features/capabilities/feature-pages all 404; universals has no fullPath and matches only slug content-types-builder, an unrelated legacy page) | api/pages | page (cross-type) | fullPath | Feature pages appear v5-native: the target page already carries draft content and there is no v4 source to read. → treat as validate-only (do NOT replace-overwrite); only migrate if a real v4 source record is located at runtime. |
| any other multi-segment path with no prefix above (e.g. /headless-cms, /headless-cms/comparison, list roots /blog, /user-stories) | api/universals (if a matching universal exists) | api/pages | page (cross-type) | fullPath = the exact path | List/index pages are page records (hero + frontend-auto-fetched lists). If no v4 universal matches the fullPath, validate-only. |
| /<slug> (top-level, no prefix above) | api/universals — FALLBACK: if no universal matches the slug, probe a same-named single-type api/<slug> (confirmed: careers→api/career, community→api/community; these hold slices[] directly, sometimes without a universal-style root hero) | api/pages | page (cross-type) | fullPath = /<slug> |
Before replace-migrating ANY page, check whether the target already has curated/v5-native content AND whether a real v4 source record exists. If there is no locatable v4 source (e.g. feature pages, some list/index pages), do NOT wipe the target — switch to validate-only (Playwright-check the existing render, report, leave content intact). replace is only safe when a canonical v4 source exists to rebuild from.
Cross-type rules: old use-case and universal records are collapsed into the new page collection. Lookup the target by fullPath (not slug), because multiple pages can share a slug across different parent paths.
If the URL path matches none of the above, skip it (add to unmatchedUrls). Do NOT guess.
Mixed batches are fine — group per-type internally so schema inspection only runs once per type.
Steps
1. Verify MCP + servers
mcp__strapi-local__strapi_list_servers()
If source_server or target_server is missing → report and stop. Do not offer to install MCP. If user clearly needs setup, recommend /setup-strapi-mcp in the final report.
2. Parse URLs and group by content type
Extract {pathSegment, slug, contentType, oldEndpoint, newEndpoint, matchField, matchValue} per URL. Drop unmatched. De-duplicate.
3. Inspect local target schema (once per content type)
Read apps/strapi/src/api/<content-type>/content-types/<content-type>/schema.json. Capture:
- Dynamic-zone field name (usually
content) and its allowed component UIDs - Required top-level fields
- Relation fields and their targets (for category-like lookups)
- Media/component fields (coverImage, logoImage, seo, etc.)
Also walk each allowed component's schema (apps/strapi/src/components/<group>/<name>.json) to capture required subcomponent fields (e.g. cards.feature-card.title, sections.feature-card-grid.items required, media.brand-logo-grid.items[].image.media required — uploading media is expensive; prefer to SKIP such slices unless explicitly requested).
4. Discover target category-like relations (once per content type)
For each relation field pointing to a taxonomy (e.g. case-study-category, blog-tag), fetch all records from the target server:
mcp__strapi-local__strapi_rest({ server: target_server, endpoint: "api/<plural>", method: "GET", params: { pagination: { limit: 100 } } })
Build a { name → documentId } map per relation. Use this at write time — do not hardcode documentIds.
5. Find target documentIds for the batch (once per content type)
For slug-matched types:
mcp__strapi-local__strapi_rest({
server: target_server,
endpoint: "api/<plural>",
method: "GET",
params: {
filters: { slug: { $in: [<slug1>, <slug2>, ...] } },
fields: ["slug", "title", "description", "companyName", "publishedAt"],
pagination: { limit: 100 },
status: "draft"
}
})
For fullPath-matched pages, use filters: { fullPath: { $in: [<"/market-guidelines">, <"/solutions/ecommerce-cms">, ...] } } and include fullPath in fields.
For each row:
- Found → reuse
documentId - Not found → create a draft shell first via POST with minimal required fields so the agent has a documentId to write to.
Do NOT query populate: { content: { fields: [...] } } — 500 error on Strapi Cloud. Use populate: { content: true } or omit populate.
5.5 Visual mapping pass — for new or ambiguous slice types
Step 6 rules are schema-aware but not visual-aware. Two slices with the same name can render very differently per page; same target component can look right or wrong depending on variant/layout flags that no rule can pick without seeing the source. This step grounds the mapping decision in actual rendered visuals before any write.
Why this exists: rule-driven migrations consistently produce data-correct but visually-divergent output. The fix isn't more rules — it's letting the agent SEE the source section and the candidate v5 component side-by-side, then choose. Skip this step only when the cache already has a decision for the exact slice type + shape.
When to run
For each batch, identify the set of (source_uid, shape_hash) pairs that appear. The shape_hash captures polymorphic variants — compute as the sorted list of populated top-level keys on the slice, joined with + (e.g. description+features+title+upperTitle vs image+logos+upperTitle+whiteCards).
- Run Step 5.5 for any (uid, shape) not in
visual-cache.json(sibling of this skill). - Skip for cached entries — Step 6 reads the cached decision directly.
Protocol — per uncached (uid, shape)
Capture source render:
- Find a URL in the batch that has this slice + shape. Navigate via
mcp__plugin_playwright_playwright__browser_navigate. - Run
mcp__plugin_playwright_playwright__browser_evaluateto locate the section. Match by the slice's resolved TITLE (per 6.0) or by the position of the slice inslices[]against H2 headings in the rendered DOM:// Find the h2 / h3 / section by text from the slice's resolved TITLE const titles = Array.from(document.querySelectorAll("h1, h2, h3")) const match = titles.find((h) => h.textContent.includes("<TITLE>")) match ?.closest('section, [class*="Section"], [class*="section"]') ?.getBoundingClientRect() - Take a tight screenshot (
browser_take_screenshotwithelementreference from the snapshot). Save to<workspace>/visual-pass/source-<uid>-<shape>.png.
- Find a URL in the batch that has this slice + shape. Navigate via
Resolve default candidate:
- Run the Step 6 rule for this
(uid, shape). Get the candidate v5 UID + variant/layout/imagePosition flags.
- Run the Step 6 rule for this
Capture candidate v5 render:
- Navigate to
https://website-ui-omega.vercel.app/dev/component-library. - Find the section labeled with the candidate component's display name (read
info.displayNamefromapps/strapi/src/components/<group>/<name>.json— e.g.sections.feature-card-grid→ "FeatureCardGrid";sections.three-column-grid→ "ThreeColumnGrid";sections.cta-banner→ "CTA Banner"). - The page has H2 headings per top-level component and H3 headings per variant. Use
browser_evaluateto locate the candidate's H2 and the nearest H3 sub-variant that matches the planned flags (e.g. variant=bordered → "Bordered Stacked Card"; size=sm → "Content size: sm"). - Take a tight screenshot of that variant. Save to
<workspace>/visual-pass/candidate-<uid>.png.
- Navigate to
Decide:
- Compare the two screenshots. Three outcomes:
- Close match: accept the Step 6 default. Cache it.
- Wrong variant: same component family but different variant/layout. Browse other H3 variants of the same H2 component. Cache the corrected flags.
- Wrong component: the candidate is the wrong v5 component entirely. Scan the page's H2 list (use
browser_evaluateto list all H2s and their y-positions) for a component whose visual matches better. Common second-guesses:cards.feature-card(no image) →sections.section-headerorcards.content-card- N×
cards.content-cardstandalone →sections.feature-card-grid(with feature-card items mapped from content-card shape) sections.feature-card-gridplain → bordered variant orsections.three-column-grid
- No good match: emit
migration.data-sinkif allowed; else SKIP. Cache withv5_component: "SKIP"+ reason.
- Compare the two screenshots. Three outcomes:
Cache the decision in
visual-cache.json:{ "<source_uid>:<shape_hash>": { "v5_component": "sections.three-column-grid", "variant": "bordered", "layout": "third", "image_position": null, "decided_at": "2026-05-24", "source_screenshot": "<workspace>/visual-pass/source-...", "candidate_screenshot": "<workspace>/visual-pass/candidate-...", "override_reason": "Step 6 default was feature-card-grid plain; visual shows bordered tiles with prominent layout — three-column-grid bordered matches the source much better." } }
Cache invalidation
The cache is a permanent record across batches. Reset by deleting visual-cache.json when:
- The component library page changes substantially (new components, redesigned variants).
- A frontend redesign changes how a v5 component renders.
- A misclassification is discovered on a later page.
Pass --reset-visual-cache (or just delete the file) before re-running a batch to re-evaluate everything.
Budget
~30s of Playwright work per uncached (uid, shape). For a typical 50-URL batch with 10-15 unique slice shapes, ~5-8 minutes upfront. Per-URL overhead afterward = 0 (cache hits). Worth it: eliminates the entire class of visual-mismatch bugs at migration time.
6. Slice → component mapping
The rules below expand components-cheatsheet.csv into runnable code-form. Consult visual-cache.json first — if there's a cached decision for the slice's (uid, shape), use it verbatim and skip the rule. Step 6 is the fallback default when no cache exists. Prefer the real target component over migration.data-sink — only fall back to data-sink if no real v5 component fits and the target schema allows migration.data-sink.
Why these rules are forgiving: v4 slice authors used inconsistent field names. Eyebrow text is upperTitle on most slices but label inside intro blocks. Repeatable items live under features[] on issues-header, capabilityCards[] on capability-cards, whiteCards[] on side-hero-with-image, cards[] on stacking-cards, integrations.data[] on integration-cards-grid. A rule that hardcodes one field name produces empty output when the slice uses a synonym. Every rule below resolves fields through aliases first, then dispatches by shape. When in doubt, look at the actual populated keys on a sample of the source data before mapping — see Step 8 populate for the shapes you need to fetch.
6.0 Universal field-resolution
For every slice, resolve these concepts via ordered alias lookup (first non-empty wins):
| Concept | Aliases (try in order) |
|---|---|
| LABEL (eyebrow / kicker above title) | upperTitle → label → eyebrow → kicker → intro.label → intro.upperTitle |
| TITLE (main heading) | title → intro.title → heading → headline |
| DESCRIPTION (body / lead text) | description → text → intro.text → intro.description → content → body |
| ITEMS (repeatable cards/tiles) | features → items → cards → capabilityCards → whiteCards → stats → companyStats → integrations.data → reviews.data |
| CTA (single primary CTA) | button → intro.button[0] → cta → ctaLink → link |
| CTAs (multiple CTAs) | buttons → ctaLinks → links → intro.button |
| IMAGE | image → cover → coverImage → media → picture |
| ICON | icon → iconImage |
| HERO_INTRO (nested hero block) | hero.intro → hero → intro → root |
When a rule says "from LABEL" it means run the LABEL resolver. When it says "items from ITEMS" it means run the ITEMS resolver. The rule body specifies which shape of v5 component to emit; the resolver picks the actual field name at runtime per source data.
If all aliases for a required concept resolve to empty/null, the rule SKIPs the slice (or that fragment of a composite) and reports the reason.
6.1 Polymorphic shape dispatch
Several v4 slice UIDs cover several different visual shapes (same UID, different populated fields). For these slices, detect what's actually present and pick the v5 component accordingly. This is more important than the per-slice tables below — when in doubt, treat the table as the default and 6.1 as the override.
| Slice | Shape (detect by) | → v5 |
|---|---|---|
slices.side-hero-with-image |
isHero === true (it's the PAGE hero, not a body section) |
sections.hero { label: upperTitle, title, description, image: <basic-image via find-before-upload>, ctas: button?[0]?.link ? [resolveLink] : [] }. PREPEND. This branch wins over the body-section shapes below when isHero is set. |
slices.side-hero-with-image |
whiteCards.length > 0 (stats grid) |
sections.three-column-grid (itemStyle=default; items=elements.how-it-works-item each { title: card.title, description: card.text, icon: <upload card.icon> }). Section from LABEL/TITLE/DESCRIPTION. |
slices.side-hero-with-image |
logos.length > 0 (brand wall) |
composite sections.section-header (LABEL/TITLE/DESCRIPTION) + media.brand-logo-grid (logos uploaded via routine) |
slices.side-hero-with-image |
image populated AND no whiteCards/logos |
composite sections.section-header + cards.feature-card (variant=bordered, layout=full, imagePosition=right with the image, ctaLinks from CTAs) |
slices.side-hero-with-image |
features.length > 0 only |
composite sections.section-header + sections.two-column-grid (items=how-it-works-item from features) |
slices.side-hero-with-image |
none populated | sections.section-header only |
slices.text-slice |
has CTA (button on slice or content.button) |
sections.cta-banner |
slices.text-slice |
theme === "purple" |
sections.section-header (inner utilities.section-header.variant=purple) |
slices.text-slice |
otherwise | sections.richtext (markdown from LABEL/TITLE/DESCRIPTION) |
slices.intro |
first slice on page AND no root-level hero with a title | sections.hero |
slices.intro |
otherwise | sections.section-header |
For each row in 6.1, the rule for that slice in the per-slice tables below points back here.
Rich text / text
| Old | Target | Rule |
|---|---|---|
slices.universal-rich-text |
sections.richtext |
{ content: richText }. SKIP if empty. |
slices.text-slice |
conditional (see 6.1) | Shape dispatch from 6.1. Field note: title/text often nest under a content component sub-field — resolve TITLE via title → content.title → intro.title and DESCRIPTION via text → content.text → description → intro.text, and deep-populate content per the Step 8 special case (else the slice reads empty and gets skipped). For the cta-banner branch: section: utilities.section-header from {label: LABEL, title: TITLE, description: DESCRIPTION, ctaLinks: CTAs}, background "dark-inverse". For the purple-theme branch: sections.section-header with inner utilities.section-header.variant = "purple". For a plain heading+lead block (TITLE+DESCRIPTION, alignCenter, no prose markdown): prefer sections.section-header (layout=center) over sections.richtext — richtext is for prose paragraphs. Only use the richtext branch for actual markdown body. CTA on a LIGHT block (recurs on case-studies — the "Enterprise Edition / Scale your Strapi project / Discover our plans" footer block): when the text-slice carries a CTA (content.button[0].link) and renders as a light promo block, emit sections.section-header (background=light, layout=center) WITH ctaLinks from content.button[0].link — do NOT drop the CTA and do NOT use cta-banner (it only offers dark backgrounds). SKIP if LABEL/TITLE/DESCRIPTION/CTA all empty. |
slices.text-with-key-numbers |
sections.three-column-grid |
INTERIM mapping (renders cleanly as a stats block, same as company-stat-list): section from intro LABEL/TITLE/DESCRIPTION; items = keyNumber[] each { title: n.number, description: n.text }; itemStyle: "default". Deep-populate populate[slices][on][slices.text-with-key-numbers][populate][keyNumber][populate]=*. SKIP only if both intro and keyNumber are empty. (Supersedes the old SKIP/"awaiting key-numbers component": three-column-grid keeps the stats VISIBLE and is NOT the retired richtext-bullets hack. Upgrade to a dedicated key-numbers component if one is later built — flagged to user.) |
Hero / intro
slices.intro is position-dependent because v4 conflated "page hero" and "section heading" into one slice. Use the position in slices[] plus the presence of a root-field hero to decide:
| Old | Target | Rule |
|---|---|---|
slices.intro |
conditional (see 6.1) | Shape dispatch from 6.1. v4 wraps fields under content.*, so the resolver looks up TITLE/DESCRIPTION/CTA via intro.*/content.* automatically. For the hero branch: sections.hero with all fields. For the section-header branch: sections.section-header with inner utilities.section-header (layout=center). SKIP if no TITLE in either branch. |
slices.new-intro |
sections.section-header |
Heading-only block. utilities.section-header from {label: LABEL, title: TITLE, description: DESCRIPTION, ctaLinks: CTAs}. SKIP if no TITLE. |
Root-level hero fields (handled outside slices[], always PREPENDED to newContent so the hero sits at the top of the page):
| Field | Target | Rule
…(truncated)