| name | migrate-strapi-content |
| description | Batch-migrate content from the old strapi.io Strapi instance (production-old MCP) to the current production Strapi (production MCP). Fully autonomous — paste URLs and nothing else is needed. Supports ALL content types — user-stories, blog-posts, news, jobs, pages (including cross-type migrations where old use-cases/universals map to new pages), comparators. Operates MCP-to-MCP (no HTML scraping). Triggers: migrate, batch migrate, migrate from production-old, bulk seed, batch seed urls, paste urls to migrate, migrate pages, migrate solutions, migrate user stories, migrate blog posts, migrate case studies, migrate comparators. |
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[], or integrations[] array of tiles.
- The optional
section field (utilities.section-header) carries the heading + description above the grid.
- 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's section field 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 like useCaseHero, 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 via sections.section-header with background=light instead)
- Quote →
testimonials.quote
- Reference to a case study →
cards.case-study-card (with target lookup for companyName/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 the hubspotForm relation 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 like slices.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 against api::review.review by 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_evaluate to locate the section. Match by the slice's resolved TITLE (per 6.0) or by the position of the slice in slices[] against H2 headings in the rendered DOM:
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_screenshot with element reference from the snapshot). Save to <workspace>/visual-pass/source-<uid>-<shape>.png.
-
Resolve default candidate:
- Run the Step 6 rule for this
(uid, shape). Get the candidate v5 UID + variant/layout/imagePosition flags.
-
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.displayName from apps/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_evaluate to 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.
-
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_evaluate to list all H2s and their y-positions) for a component whose visual matches better. Common second-guesses:
cards.feature-card (no image) → sections.section-header or cards.content-card
- N×
cards.content-card standalone → sections.feature-card-grid (with feature-card items mapped from content-card shape)
sections.feature-card-grid plain → bordered variant or sections.three-column-grid
- No good match: emit
migration.data-sink if allowed; else SKIP. Cache with v5_component: "SKIP" + reason.
-
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 |
|---|
useCaseHero (use-case records) | sections.hero | Build from useCaseHero.hero.intro.{label, title, text, button, cliContent?, smallTextWithLink?, newsWithLink?}. SKIP if no useCaseHero.hero.intro.title. |
homeHero (home universal) | sections.hero-home OR PRESERVE existing | Path is homeHero.hero.intro.{theme, title, text, button, cliContent, smallTextWithLink, newsWithLink} (same nesting as useCaseHero). When the target record already has a sections.hero-home at content[0], preserve that component verbatim and DO NOT emit a new hero from this field — the v5 hero-home schema is custom and has been hand-tuned. See Step 9 hero-preservation rule. When the target has no existing hero, build a sections.hero from homeHero.hero.intro.* as the fallback. |
whiteHero (relevant universals) | sections.hero | Same nesting as homeHero (.hero.intro.*). |
careersHero (careers universal) | sections.hero | Same nesting. If careersHero.image (or .coverImage) is present, upload it via the media policy and attach. |
featuresHero (relevant universals) | sections.hero | Same nesting. |
communityHero (community universal) | sections.hero + media.brand-logo-grid | PREPEND sections.hero from communityHero.hero.intro.{label, title, text, button}. Then, if communityHero.brandsWithIntro is present, APPEND a media.brand-logo-grid built from its logos. |
Always populate this set in the explicit populate spec (Step 8) for the relevant content types — populate=* won't reach into useCaseHero.hero.intro etc.
CTA field name gotcha: all of these map to sections.hero (or sections.hero-home), whose repeatable CTA field is ctas (utilities.link) — NOT ctaLinks like section-header/feature-card/cta-banner. Map source intro.button[0].link → ctas: [resolveLink(button[0].link)]. Hero scalar mapping: label→label, title→title, text→description (richtext). Using ctaLinks on the hero silently drops the CTA (Strapi ignores unknown keys — no error).
resolveLink() output shape (applies to EVERY ctas/ctaLinks entry skill-wide, not just heroes): the v5 utilities.link schema is { type: "external"|"page", label: string, href: string, newTab: boolean } — there is NO text and NO target field. Map the v4 link component: link.text → label, link.target ("_self"→false, "_blank"→true) → newTab, link.href → href, and set type: "external" for absolute URLs and internal paths. Sending text/target fails the PUT with 400 Invalid key target.
Cards (single tile, no grid wrapper)
The v5 cards.feature-card layout enum is full | half | third. CSV terminology "split image right/left" means the visual style where the card has text on one side and image on the other — that's layout: "full" with imagePosition set. There is no split layout value.
| Old | Target | Rule |
|---|
slices.section-with-image | cards.feature-card | { title, description: text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", size: "default", layout: "full", ctaLinks: button?.link ? [resolveLink(button)] : [] }. Note the inversion: v4 textPosition = where text is; v5 imagePosition = where image is. Flip it. SKIP if no title. |
slices.text-next-to-image | cards.feature-card | Same as section-with-image. { title: title || content?.title, description: text || content?.text, imagePosition: textPosition==="left"?"right":"left", variant: "bordered", layout: "full", ctaLinks: (content?.button || []).map(resolveLink) }. SKIP if no title. |
slices.text-next-to-big-image | cards.feature-card | { title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }. Single card, image right. SKIP if no title. |
slices.simple-text-next-to-image | cards.feature-card | { title, description: text, imagePosition: "left", variant: "bordered", layout: "full", ctaLinks: [...] }. Single card, image LEFT. SKIP if no title. |
slices.text-with-image-and-gradient | cards.feature-card | { title, description: text, imagePosition: "right", variant: "bordered", layout: "full", ctaLinks: [...] }. The v4 DownloadLink (or button.download) becomes a ctaLink — preserve its href and label. SKIP if no title. |
slices.side-hero-with-image | polymorphic (see 6.1) | Shape dispatch by populated arrays: whiteCards → three-column-grid of stats; logos → section-header + brand-logo-grid; image only → section-header + feature-card; features only → section-header + two-column-grid; otherwise → section-header only. See 6.1 for the exact mapping per shape. SKIP if no shape fires AND LABEL/TITLE/DESCRIPTION all empty. |
Grids of features / cards
Two patterns here:
- Single-component grids: emit one
sections.feature-card-grid or sections.two-column-grid. The grid's section field carries the heading — no separate sections.section-header needed.
- Composites: emit a standalone
sections.section-header when the heading needs styling the built-in section field can't provide (e.g. boxed + dark wrapper). Otherwise prefer single-component.
sections.feature-card-grid.items is required and must be cards.feature-card — content-cards cannot be grid items. If the design calls for content-cards, emit them as N standalone cards.content-card entries in the dynamic zone (allowed by the page schema).
⚠️ GRID ITEM FLAGS (recurring mistake — get this right): every cards.feature-card INSIDE a feature-card-grid uses layout: "third" (3-column) or "half" (2-column) — NEVER layout: "full" and NEVER imagePosition. full/imagePosition are ONLY for a SINGLE standalone cards.feature-card (the section-with-image image-split family), not grid items — using them makes each tile a full-width bordered box (wrong). For plain icon/title/text tile grids (slices.features-slice, slices.features-grid, multi-column feature lists like financial-services' "Any channel, any device") use variant: "plain" and put the source per-card icon in the feature-card icon field (not image). Reserve variant: "bordered" for grids that are genuinely bordered by design (integration-cards-grid, getting-started-grid, features-card). When unsure, a multi-column tile grid defaults to variant: plain, layout: third, no imagePosition. BENTO EXCEPTION: a curated bento feature-card-grid may DELIBERATELY mix full/half/third for visual variety (wide hero card + smaller cards) and render well — if the target already has a deliberate mix that renders as a bento, PRESERVE it. Only "fix" full when it's applied UNIFORMLY to what should be a plain equal-tile grid (the actual error). Verify visually before flattening. imagePosition is INERT on image-less tiles — don't chase it: the enum is left|right with default:"right" and NO null, so Strapi re-applies "right" on every write even if omitted. The frontend gates image rendering on hasImage (image != null), so on a variant:plain tile with image:null the residual imagePosition:"right" has zero visual effect. The load-bearing flags are variant + layout + presence-of-image; a persisting imagePosition:"right" on a plain image-less tile is NOT a defect.
sections.two-column-grid.section is REQUIRED — always populate it. Items are elements.how-it-works-item (icon + title + description); the icon field is optional and matches v4 feature.icon when present.
| Old | Target | Rule |
|---|
slices.top-features | sections.two-column-grid | Single component. section: utilities.section-header REQUIRED — {label: LABEL, title: TITLE, description: DESCRIPTION} with fallback { title: "Features" }. items from ITEMS (typically features[]) filtered by title, each { title: f.title, description: f.description ?? f.text, icon: f.icon ? <upload> : undefined }. SKIP if no items. Feature .links are dropped — how-it-works-item has no ctaLinks. |
slices.large-features-slice | sections.two-column-grid | Same shape as top-features. |
slices.features-slice | sections.feature-card-grid | section from LABEL/TITLE/DESCRIPTION. items from ITEMS (typically cards[]) filtered by title, layout: s.layout==="two"?"half":"third", variant: "plain". SKIP if empty. |
slices.stacking-cards | composite: sections.section-header + N × cards.content-card | Emit sections.section-header from LABEL/TITLE/DESCRIPTION (skip header if empty). Then for each card in ITEMS (try cards[], items[]) filtered by title, emit one cards.content-card with { label: card.label, title: card.title, content: card.description ?? card.text } (content REQUIRED — SKIP cards with no body). |
slices.text-with-cards | sections.feature-card-grid | Single component. section from LABEL/TITLE/DESCRIPTION. items from ITEMS filtered by title, each { title, description, icon: c.icon ? <upload> : undefined, variant: "plain", size: "sm", layout: "third" }. SKIP if empty. |
slices.features-card | sections.feature-card-grid | Single component on light background. section from LABEL/TITLE/DESCRIPTION. items from ITEMS filtered by title, each { title, description, imagePosition: "right", variant: "bordered", layout: "third" }. SKIP if empty. |
slices.getting-started-grid | sections.feature-card-grid | section from LABEL/TITLE/DESCRIPTION. items from ITEMS (try cards[], features[]) filtered by title, each { title, description, variant: "bordered", layout: "third" }. SKIP if empty. |
slices.integration-cards-grid | sections.feature-card-grid | section from intro.{label, title, text} (LABEL/TITLE/DESCRIPTION will resolve to intro.* per the alias list). items = integrations.data[] filtered by attributes.title, cap at 12, each { title: attributes.title, description: attributes.description, ctaLinks: [{ type:"external", label: slice.buttonText ?? "Learn more", href:"/integrations/"+attributes.slug, newTab:false }] }. SKIP if empty. |
slices.company-stat-list | sections.three-column-grid | Single component. section REQUIRED — from LABEL/TITLE/DESCRIPTION; fall back to { title: "Company Stats" }. items from ITEMS (try stats, companyStats, features), each { title: stat.value ?? stat.number ?? stat.title, description: stat.label ?? stat.text }. itemStyle: "default". SKIP if no items. May also appear as root field on careers content type. |
slices.issues-header | sections.three-column-grid | Single component, itemStyle: "bordered". section REQUIRED — from LABEL/TITLE/DESCRIPTION; fall back to { title: "Issues" }. items from ITEMS (on this slice typically features[]) filtered by title, each { title: f.title, description: f.text ?? f.description }. SKIP if no items. |
Tabbed content
| Old | Target | Rule |
|---|
slices.capability-cards | sections.tabbed-feature-overview | section: utilities.section-header REQUIRED — from LABEL/TITLE/DESCRIPTION on the slice (fall back to { title: "Capabilities" }). tabs[] = ITEMS (on this slice typically capabilityCards[]) mapped to elements.tabbed-feature each: { tabLabel: c.upperTitle ?? c.label ?? c.title (REQUIRED — SKIP tab if absent), tabIcon: c.icon ? <upload> : undefined, content: sections.feature-overview { label: c.upperTitle ?? c.label, title: c.title (REQUIRED), description: c.text ?? c.description, ctaLinks: c.button ? [resolveLink(c.button)] : [], image: <upload c.image — REQUIRED, SKIP tab if no image survives>, items: (c.features ?? []).map(f => ({ title: f.title, description: f.text ?? f.description, icon: f.icon ? <upload> : undefined })) } }. SKIP whole slice if no tabs survive. Image uploads use the find-before-upload routine. |
Auto-fetched lists
These components have empty schemas — the frontend auto-fetches data. Migration just registers the component on the page.
| Old | Target | Rule |
|---|
slices.news-list | sections.news-list | Emit { __component: "sections.news-list" } with no fields. The target schema has zero attributes — frontend auto-fetches news items chronologically. Always succeeds; never SKIP. |
Reviews
| Old | Target | Rule |
|---|
slices.reviews-slider | sections.reviews (preferred) OR N × testimonials.quote (fallback) | The v4 slice has reviews INLINE: each reviews.data[*].attributes has author.{name, description, image} and text. First try the new component: emit sections.reviews with { title: TITLE ?? "What customers say" (REQUIRED), subTitle: LABEL, description: DESCRIPTION, reviews: <resolved relation ids> }. Resolve reviews relation by looking up each author.name against api/reviews?filters[author][$eq]=<name>&fields=id,author on the target server. If at least one resolves, emit sections.reviews. If NO author names resolve to existing target review records (the typical case when the target review collection isn't seeded), emit nothing for sections.reviews and instead fall back to emitting N × testimonials.quote directly into the dynamic zone using the inline data: { quote: r.text, authorName: r.author.name, authorRole: r.author.description ?? r.role ?? "", variant: "boxed" }. Either branch preserves the content. Report the choice in the per-page log. |
Section header / heading-only blocks
For any slice that's essentially a label+title+description with no items below, emit sections.section-header. The inner utilities.section-header carries label/title/description/ctaLinks; the outer wrapper controls background and boxed treatment.
| Old | Target | Rule |
|---|
slices.chili-piper | sections.section-header | { section: { label, title, description: text, ctaLinks: button ? [resolveLink(button)] : [] }, background: "none", boxed: false }. SKIP if no title. |
slices.content-videos-list | sections.section-header | Same shape. The video items themselves are not migrated (out of scope unless caller opts into video uploads). |
CTA banners
sections.cta-banner.background enum is dark | dark-inverse (default dark-inverse) — there is no light variant. If a v4 CTA needs a light treatment, fall through to sections.section-header with background: "light" instead.
| Old | Target | Rule |
|---|
slices.dark-cta-banner | sections.cta-banner | { section: { label, title, description: text, ctaLinks: button ? [resolveLink(button)] : [] }, background: "dark" }. Section field is REQUIRED. SKIP if no title. |
Forms
| Old | Target | Rule |
|---|
slices.newsletter-banner | forms.newsletter | Limited migration. forms.newsletter has only a hubspotForm relation — frontend renders fixed copy. Resolve the v4 slice's HubSpot form id against the target's api::hubspot-form.hubspot-form collection (match on form id or name). If unresolvable, SKIP and report under skipped with reason "newsletter has no resolvable hubspot-form". |
slices.embed-form | SKIP (by default) | forms.hubspot-form requires a relation to an existing api::hubspot-form.hubspot-form record. Without a mapping config, skip + report. Unchanged. |
Disclaimer
| Old | Target | Rule |
|---|
slices.disclaimer | sections.disclaimer | { title: slice.title, content: slice.content || slice.text }. content is REQUIRED — SKIP if empty. If the route is one where the frontend renders disclaimer copy hardcoded, the caller should pass an explicit skip list; otherwise emit. |
Brand logos — media uploads enabled
Brand-logo slices used to be SKIPPED to avoid media uploads. That non-goal has been retired: the skill now uploads logos from the old CDN, deduping by filename to avoid creating duplicate media entries.
| Old | Target | Rule |
|---|
slices.brands-with-intro | composite: sections.section-header + media.brand-logo-grid | Emit sections.section-header from intro ({label, title, description: intro.text, layout: "center"}). Then emit media.brand-logo-grid populated by the upload-and-dedupe routine below. If the grid ends up with zero items (all uploads failed), emit only the section-header; if the intro is also empty, SKIP the whole slice. |
slices.brands | media.brand-logo-grid | No intro available — emit just the grid. SKIP if no items survive upload. |
Logo upload routine (also used by media.image-gallery and root-level hero images):
for each logo in source.logos[]:
url = logo.image.data.attributes.url (absolute on old CDN if it starts with http; otherwise prefix with old CDN host)
stem = FULL filename without extension, hash included (e.g. "rocket_5210f84d20.svg" → "rocket_5210f84d20")
altText = logo.image.data.attributes.alternativeText ?? logo.name ?? ""
# find-before-upload (dedupe by filename)
# Use the FULL stem (with hash), NOT a truncated prefix — a short stem like
# "Content_bloc" $containsi-matches the wrong file (e.g. "Content_Blocks_Blog.gif").
existing = GET /api/upload/files?filters[name][$containsi]=<stem> on target_server
exact = existing.filter(f => f.name without extension === stem) # prefer exact stem match
if exact.length:
mediaId = max(exact, by id).id
elif existing.length:
mediaId = max(existing, by id).id # last-resort: most recent contains-match
else:
downloaded = curl -L <url> → temp file
mediaId = mcp__strapi-local__strapi_upload_media({
server: target_server,
filePath: temp file,
name, alternativeText: altText
}).id
items.push({
image: { media: mediaId, alt: altText },
hasLink: !!logo.link,
link: logo.link ? resolveLink(logo.link) : undefined,
tooltip: logo.tooltip ? { content: logo.tooltip } : undefined
})
If a single logo fails to upload (network/permission), keep the item out of the grid and continue — the report's mediaUploaded field aggregates per-page upload results.
Media
| Old | Target | Rule |
|---|
slices.large-video | media.video | { url, alignment: "center" }. SKIP if url missing. Unchanged. |
slices.image-slider | media.image-gallery | images[] populated via the upload-and-dedupe routine (same as brand logos). variant: "contained" (or "full-bleed" if the slice has a fullBleed flag). SKIP if no images survive upload. |
Quotes
| Old | Target | Rule |
| ------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| slices.quote | testimonials.quote | { quote, authorName: author?.name \|\| "Strapi", authorRole: author?.description \|\| "", variant: "boxed" }. SKIP if no quote. |
| slices.full-width-quote | testimonials.quote | Same fields. **testimonials.quote.variant enum is boxed | imageONLY — there is NOfullwidth** (sending it fails validation). Use variant: "boxed"(or"image" if the source has an author image to feature). SKIP if no quote. |
FAQ / interview
The interview slice now gets a dark/boxed wrapper around the Q&A list. We keep sections.faq-section for the Q&A semantics (utilities.accordions.{question, answer} shape) but prepend a section-header for the visual treatment.
| Old | Target | Rule |
|---|
slices.interview | composite: sections.section-header + sections.faq-section | Emit sections.section-header first with { section: { label: slice.label, title: slice.title, description: slice.intro || slice.text }, background: "dark", boxed: true }. Skip header if all of label/title/intro are empty. Then emit sections.faq-section with { items: questionAnswer.filter(qa => qa.question && qa.answer).map(qa => ({ question, answer })) }. SKIP the FAQ fragment if questionAnswer is empty after filter; SKIP the whole composite if both fragments would be empty. |
Case study reference
| Old | Target | Rule |
|---|
slices.case-study-card | cards.case-study-card | The v5 component is a relation-only wrapper — schema is just { caseStudy: relation to api::case-study.case-study } (companyName/title/cta come from the related record itself when the frontend renders). slug = card?.data?.attributes?.slug. Look up on target server: api/case-studies?filters[slug][$eq]=<slug>&fields=slug. If found, emit { caseStudy: <documentId> } (pass the documentId string, not an object). SKIP if slug missing or target case study not found. Do NOT send companyName, title, or ctaLink — those fields don't exist on the v5 schema and Strapi will reject the PUT. |
Algorithmic / always-skip
| Old | Target | Rule |
|---|
slices.related-case-studies | SKIP | Rendered algorithmically on the frontend; migrating creates duplicate content. Unchanged. |
Fallback