references/aem.md
# AEM to Sanity
## What to Determine First
Adobe Experience Manager projects vary widely. Before writing migration code, determine:
- AEM 6.5 on-premise or AEM as a Cloud Service.
- Author-tier access, publish-tier access, or exported content only.
- Whether content is mostly pages with components, Content Fragments, Experience Fragments, DAM assets, or a mixture.
- Whether the site uses Core Components, custom components, or both.
- Whether Multi-Site Manager language copies are in scope.
- Whether unpublished drafts, inherited content, and Experience Fragments must be preserved.
## Authentication
- AEM 6.5/on-premise commonly supports HTTP Basic auth with a read-only service account.
- AEM as a Cloud Service production does not use Basic auth; use Adobe IMS/service credentials or another approved bearer-token flow.
- Publish tier exposes published content only. Use author-tier access when drafts, unpublished content, or inherited authoring state is in scope.
## Extraction Paths
Use the most structured source that covers the required scope:
- **Content Fragments:** use AEM GraphQL. Discover types from `/content/_cq_graphql/global/endpoint.GQLschema`, then query fragments through `/content/_cq_graphql/global/endpoint.json`.
- **Core Component pages:** use `.model.json` where Sling Model exporters are present.
- **Custom component pages:** use `.infinity.json` or `.tidy.infinity.json` to inspect raw JCR nodes under `jcr:content`.
- **Bulk discovery:** use QueryBuilder to enumerate pages, fragments, DAM assets, tags, and Experience Fragments.
- **DAM assets:** fetch binaries from `/content/dam/...` and metadata from `/content/dam/.../jcr:content/metadata.json`.
- **Dialog/schema discovery:** if repository or component paths are available, inspect `_cq_dialog.infinity.json` and `sling:resourceSuperType` chains to infer component fields.
Save raw extraction responses to disk before transforming. AEM APIs can be slow, auth-sensitive, and environment-specific.
## `aem-to-sanity` Toolkit Pattern
For large AEM migrations, use or mirror the staged `aem-to-sanity` toolkit pattern from `github.com/demo-repositories/aem-to-sanity`.
Pipeline stages:
- **Schemas (`migrate:schema`):** fetch `_cq_dialog.infinity.json`, walk Granite UI dialogs and `sling:resourceSuperType` chains, discover slots/container drop zones, and emit Sanity object/page builder schemas.
- **Types (`typegen`):** generate `sanity.types.ts` from emitted schemas.
- **Extract (`aem-extract`):** walk content roots through `.infinity.json`, following depth truncation markers, and write raw files under an AEM cache directory.
- **Tags (`aem-tags`):** emit taxonomy/category documents from `/content/cq:tags/...` when AEM tags are in scope.
- **Transform (`aem-transform`):** map `sling:resourceType` through the generated registry, coerce string booleans/numbers, convert HTML to Portable Text, and resolve tag refs.
- **Assets (`aem-assets`):** dedupe, download from AEM, upload to Sanity Media Library or assets, link into the dataset, and rewrite clean documents.
- **Import (`aem-import`):** commit documents with `transaction().createOrReplace()`, importing categories before pages.
Operational defaults from the toolkit:
- Dry-run by default; real writes require `MIGRATION_DRY_RUN=false`.
- Per-tenant folders hold credentials, component paths, content roots, tag roots, component exceptions, generated caches, and reports.
- Run a doctor step before migration to catch missing env/config drift.
- Use content-derived identity: JCR path -> `_id`, JCR UUID -> `_key`, DAM path -> asset manifest key.
- Treat unknown component shapes as findings in reports, not immediate fatal errors, unless config or schema validation is broken.
## Mapping to Sanity
Map AEM shapes by editorial meaning, not JCR structure:
- `cq:Page` with authored components can become a `page` document with a page builder array.
- Content Fragments usually become standalone document types.
- Reusable Content Fragments, Experience Fragments, tags, people, products, or snippets should become referenceable documents when reused.
- AEM text/RTE components become Portable Text fields or custom Portable Text objects.
- AEM image components become Sanity image fields with migrated asset references.
- AEM tags become taxonomy documents before content documents are written.
- MSM language copies can map to document-level localization with `language` fields and translation metadata documents.
Use `sling:resourceType` as the main discriminator for component-to-object mapping.
## Transformation Notes
- Strip JCR/system fields such as `jcr:primaryType`, `jcr:created`, and `sling:resourceType` unless they are needed in migration metadata.
- Convert RTE HTML to Portable Text with source-specific preprocessing for empty paragraphs, ` ` spacer rows, inline styles, spans, tables, and double-encoded entities.
- Normalize paths and slugs. AEM URLs often include `/content/<site>/<locale>/...` prefixes and `.html` extensions.
- Deduplicate DAM assets by full DAM path, asset UUID, or source URL before upload.
- Use deterministic IDs based on AEM paths or UUIDs.
- Create translations and parent/child references after all target documents exist.
## Load Order
1. Taxonomies/tags from `/content/cq:tags`.
2. DAM assets or asset manifest.
3. Content Fragments and reusable Experience Fragments.
4. Page/content documents.
5. Translation metadata and post-import relationship fixes.
## Gotchas
- `.model.json` can silently omit custom components without Sling Model exporters. Cross-check representative pages against `.infinity.json`.
- `.infinity.json` may be depth-limited or truncated on large trees. Detect truncation and recurse into child paths if needed.
- Publish-tier APIs expose published content only. Use author-tier access when drafts or unpublished changes are in scope.
- DAM URLs may require auth or point at internal hostnames. Verify asset fetchability before a large import.
- AEM tags are namespaced paths, not flat slugs.
- MSM inheritance can produce locale pages with inherited source-language fields. Detect identical translated fields before assuming content is localized.
- Experience Fragments live in a separate content tree and must be migrated before pages that reference them.
- Source-edited RTE content can contain layout tables, scripts, or arbitrary HTML that needs manual cleanup.
## Validation Checklist
- Compare page, fragment, tag, Experience Fragment, and DAM counts against source queries.
- For each major template, verify every `sling:resourceType` has an explicit Sanity mapping or an intentional skip.
- Spot-check `.model.json` and `.infinity.json` parity for custom components.
- Confirm language variants have correct slugs, `language` fields, and translation metadata.
- Confirm all DAM references resolve to Sanity assets or Media Library assets.
- Crawl old AEM URLs and verify redirects to the new frontend.
references/contentful.md
# Contentful to Sanity
## What to Determine First
Before writing migration code, determine:
- Space ID, environment, locales, and whether multiple environments must be reconciled.
- Access level: Content Delivery API, Content Preview API, Content Management API, CLI export, or export file.
- Whether drafts, archived entries, unpublished entries, and deleted entries are in scope.
- Whether the migration should use the `contentful-to-sanity` package for a first pass or a custom transform for remodeling.
- Rich text field formats, embedded entries/assets, and any custom app fields.
- Which content types should become Sanity documents, objects, page builder blocks, references, or be retired.
## Extraction Paths
Common options:
- **Contentful CLI export:** best for a complete file-based snapshot of content types, entries, assets, locales, and editor interface metadata.
- **Content Management API:** best when drafts, unpublished content, archived entries, or environment-specific migration logic are needed.
- **Content Delivery API:** good for published-only migrations and frontend parity work.
- **Content Preview API:** useful when draft or preview state matters but full management access is not available.
- **`contentful-to-sanity`:** Sanity-maintained tooling that can create a Sanity project/schema and prepare a `dataset.ndjson` from a Contentful space. Use it for quick lift-and-shift or as an exploration tool, then review the generated schema before committing to it.
Snapshot exports before transformation so field mappings can be iterated offline.
If using the official Contentful CLI directly:
```bash
npx contentful-cli login
npx contentful-cli space export --space-id <space-id> --export-dir ./contentful-export
```
The export JSON should contain `contentTypes`, `entries`, `assets`, and `locales`. Analyze those before designing the transform. If credentials are already configured locally, a management token may exist in `~/.contentfulrc.json`, but do not assume it is present or safe to use without user confirmation.
## Using `contentful-to-sanity`
Use `contentful-to-sanity` when the project needs a fast first-pass lift-and-shift, schema exploration, or a generated `dataset.ndjson` to inspect. Do not treat its generated schema as final without content modeling review.
Inputs to collect:
- Contentful space ID.
- Contentful Content Delivery API access token.
- Contentful Content Management API token.
- Destination Sanity project and dataset, usually a clean Studio or scratch dataset for review.
Typical workflow:
```bash
npm create sanity@latest -- --template clean --create-project "Migrated Contentful" --dataset production --output-path ./migrate
npx contentful-to-sanity@latest -s <space-id> -t <management-token> -a <delivery-token> ./migrate
cd ./migrate
npx sanity dataset import ./dataset.ndjson production
```
If the generated Studio imports schemas from `./schemas`, update it to use the generated schema export from `./schema` as instructed by the package output.
Review before using the output as production architecture:
- Generated document and field names.
- Contentful content types that should be consolidated, split, or remodeled.
- Reference fields and embedded entry handling.
- Rich Text conversion quality, especially embedded entries and assets.
- Locale behavior and fallback values.
- Asset metadata and missing alt text.
- Whether IDs are deterministic enough for reruns and future deltas.
Use custom extraction/transform code instead when:
- Drafts, archived entries, unpublished entries, or deleted entries are in scope.
- The target Sanity model intentionally differs from the Contentful model.
- Rich Text embedded entries need custom Portable Text or page builder objects.
- Locales need a document-level localization model rather than the generated default.
- The migration must run repeatedly for delta sync before cutover.
- You need detailed quality reports, custom validation, or source-specific cleanup.
## Content States and Locales
Contentful state needs explicit mapping:
- **Draft:** `fieldStatus` is `draft`, `publishedCounter` is `0`, and no published version exists. Import only a Sanity draft document: `drafts.<documentId>`.
- **Published:** `fieldStatus` is `published`; current data matches the live version. Import only the published Sanity document: `<documentId>`.
- **Changed:** the entry was published, then edited. Import two documents: `drafts.<documentId>` from the export/current draft data, and `<documentId>` from the Content Delivery API's last published data.
When the Contentful export contains more than one locale, use document-level localization unless the project explicitly uses a field-level model:
- Determine the base locale from the export, not from assumptions.
- Use the source `sys.id` for the base document ID.
- Use `<sys.id>__i18n_<locale>` for translation document IDs.
- Use `translation.<sys.id>` for translation metadata.
- Create translation documents only when that locale has actual field values.
- Omit missing localized fields instead of copying fallback values into translation documents.
- Apply the draft/published/changed logic to every locale variant.
- Convert Contentful Rich Text separately per locale.
## Mapping to Sanity
Map Contentful concepts deliberately:
- `Symbol` maps to `string`.
- `Text` maps to `text`.
- `RichText` maps to Portable Text.
- `Integer` and `Number` map to `number`.
- `Date` maps to `datetime`.
- `Media` maps to `image` or `file`.
- `Link` to Entry maps to `reference`.
- Arrays map to Sanity arrays, with array members chosen from the field's item type.
- Content types usually become Sanity document types when independently managed or queried.
- Contentful fields that are only meaningful inside one parent become Sanity object fields or named object types.
- Single-reference and multi-reference fields become Sanity references.
- Contentful assets become Sanity image/file assets with asset field metadata such as title, description, alt text, and source URL where useful.
- Contentful locales can map to field-level localization or document-level localization depending on the target editorial workflow.
- Contentful Rich Text should become Portable Text, preserving marks, embedded entries, embedded assets, hyperlinks, and custom blocks as supported by the target schema.
Use the migration to reduce over-generic content types and remove fields that exist only because of Contentful app or layout constraints.
If a Contentful field is an array of strings but behaves like a taxonomy, promote it to reference documents instead of preserving it as strings. Good candidates include product tags, article tags, topics, industries, audiences, and filters used by navigation/search. Create the taxonomy documents first, then reference them from migrated entries.
## Transformation Notes
- Preserve original Contentful entry IDs in deterministic Sanity IDs or migration metadata.
- Build ID maps for every entry and asset before transforming references.
- Convert Rich Text JSON structurally instead of rendering to HTML first when possible. Use `@portabletext/contentful-rich-text-to-portable-text` when it fits the target schema, then add custom handling for embedded entries/assets.
- Handle embedded entries with explicit mapping decisions: inline object, reference block, page builder object, or skipped unsupported block.
- Preserve locale fallbacks intentionally. Do not flatten fallback values into translated documents without recording that they were inherited.
- Import reusable taxonomies, people, and shared modules before documents that reference them.
## Gotchas
- Published-only APIs omit draft and unpublished content.
- Contentful locale fallbacks can hide missing translations. Decide whether to preserve missingness or fill with fallback content.
- Rich Text embedded entries often carry presentation-specific modules. They need custom Portable Text or page builder object mappings.
- Asset fields may store title/description but not enough accessibility metadata. Flag missing alt text for editorial review.
- Contentful asset exports can be incomplete or corrupt. Check downloaded asset files for zero-byte size, missing files, unexpected content type, and failed URLs before building `_sanityAsset` references.
- Some generated schemas from automated tools are a starting point, not a final content model.
- Contentful field validations and required flags do not guarantee every historical entry is clean.
- If only code is available, inspect `contentfulClient.getEntries({content_type: ...})`, GraphQL queries, Contentful TypeScript interfaces, component props, and `contentful/migrations/` files to reconstruct the content model.
## Validation Checklist
- Compare content type, entry, asset, and locale counts with the source export.
- Confirm every Contentful content type is mapped, skipped, or intentionally consolidated.
- Confirm all reference fields resolve to existing Sanity document IDs.
- Spot-check Rich Text entries with embedded assets, embedded entries, tables, lists, and links.
- Verify promoted string taxonomies have one canonical document per unique value and all source entries reference those documents.
- Verify every exported asset path exists and has a non-zero file size before import.
- Verify locale fallback behavior with real translated and untranslated entries.
- Verify draft, published, and changed entries produce the expected Sanity draft/published documents.
- Verify translation metadata documents link all language variants for each entry.
- Confirm frontend GROQ queries return shapes equivalent to the old Contentful queries or the agreed new contract.
references/drupal.md
# Drupal to Sanity
## What to Determine First
Before writing migration code, determine:
- Drupal major version.
- Whether Drush, database, admin UI, repository config, JSON:API, REST, or GraphQL access is available.
- Which node types, taxonomy vocabularies, Paragraphs, media entities, menus, Views, users, redirects, and translations are in scope.
- Whether content moderation, revisions, drafts, unpublished nodes, aliases, and redirects need to be preserved.
- Whether rich text is Filtered HTML, Full HTML, Markdown, CKEditor output, or embedded media/shortcode-like tokens.
## Schema Discovery
Use Drush and configuration export when available:
```bash
composer require drush/drush
drush config:export --destination=./drupal-config
drush config:get node.type --include-overridden
drush config:get field.storage --include-overridden
drush config:get field.field --include-overridden
drush entity:info node_type
```
Key config files:
- `node.type.*.yml`: content type definitions.
- `field.storage.*.yml`: reusable field storage definitions.
- `field.field.*.yml`: field instances on bundles.
- `core.entity_view_display.*.yml`: display config; useful for identifying presentation-only fields.
- `taxonomy.vocabulary.*.yml`: taxonomy vocabularies.
- Paragraphs config: paragraph bundles and fields when the Paragraphs module is used.
If Drush is unavailable, inspect exported config, custom modules, entity query usage, Twig templates, Views config exports, and field definitions in code.
## Extraction Paths
Choose the most complete source:
- **Drush/config + database:** best for full-fidelity migrations and schema discovery.
- **Drupal JSON:API:** useful for structured extraction when enabled; include relationships and pagination explicitly.
- **REST/GraphQL:** use when configured and complete enough for the target scope.
- **Database dump:** complete but requires joining entity tables, field tables, revisions, files, and taxonomy terms.
- **Static crawl:** fallback for public content only; loses entity relationships and editorial metadata.
Snapshot raw entity exports before transforming. Drupal field tables and revisions can be hard to reason about after the fact.
## Mapping to Sanity
- Node types become Sanity document types.
- Taxonomy vocabularies and terms become reference documents.
- Entity references become Sanity references.
- Paragraph bundles become Sanity object types, Portable Text custom objects, or page builder blocks.
- Media entities and file/image fields become Sanity image/file assets.
- Menus can become navigation singleton documents or frontend configuration.
- Views usually become frontend GROQ queries or route/listing logic, not migrated content.
- URL aliases and redirect entities become redirect data or frontend redirect config.
## Transformation Notes
- Use deterministic IDs from entity type, bundle, entity ID, and language, such as `node.article.123.en`.
- Import taxonomy terms, media, users/authors, and reusable referenced entities before nodes.
- Preserve `path.alias` or alias tables as legacy URLs for redirects.
- Convert rich text after applying or accounting for Drupal text filters. Filtered HTML may contain tokens, media embeds, or text-format-specific markup.
- Resolve file IDs to real file URLs or local file paths before using `_sanityAsset`.
- For Paragraphs, enumerate all paragraph bundle types across all content, not only from a sample node.
## Gotchas
- Drupal field storage and field instances are separate. Read both before mapping fields.
- Revisions and moderation states may contain unpublished content not visible through public APIs.
- Paragraphs can be deeply nested and reused differently across node types.
- Views are presentation/query configuration; do not blindly model every View as content.
- URL aliases, redirects, and menu links may live outside node records.
- Translations may be entity-level or field-level depending on site configuration.
## Validation Checklist
- Compare counts by node type, taxonomy vocabulary, media type, language, and published/moderation state.
- Confirm every field storage/field instance pair is mapped, skipped, or intentionally consolidated.
- Confirm every Paragraph bundle has a Sanity object/block mapping or an intentional skip.
- Confirm file/media references resolve to accessible assets.
- Confirm aliases and redirects cover old public URLs.
- Spot-check translated nodes and unpublished/draft content if they are in scope.
references/general.md
# General Sanity Migration Playbook
## Core Stance
Replatforming to Sanity is a chance to improve the content model, editorial workflow, and frontend data contract. Do not recreate the source CMS shape unless the project explicitly needs a temporary lift-and-shift phase.
Use a two-phase approach when risk or timeline is high:
1. **Stabilize:** migrate into a close, understandable model with stable IDs, complete assets, and working frontend queries.
2. **Improve:** remodel page-shaped content into semantic documents, references, page builder sections, or reusable entities after fidelity is proven.
For large migrations, write deterministic migration scripts, review mappings carefully, and identify edge cases before import. The migration must be repeatable and idempotent.
## Discovery Questions
Before writing code, establish:
- **Source access:** live API, admin API, export file, database dump, repository access, static HTML, crawler, or manual exports.
- **Content scope:** which content types, locales, drafts, archived content, redirects, menus, authors, taxonomies, assets, and metadata are in scope.
- **Volume:** document counts per type, asset counts, and which types are high-risk because of rich text or nested components.
- **Rich text formats:** HTML, Markdown, WordPress blocks, Strapi blocks, Lexical, Contentful Rich Text, custom JSON, or page-builder structures.
- **Relationships:** authors, taxonomies, products, reusable CTAs, testimonials, media, related content, and cross-locale links.
- **Destination schema:** existing Sanity schema, new schema, temporary lift-and-shift schema, or a planned remodel.
- **Frontend contract:** which routes and queries must keep working, which legacy URLs need redirects, and which pages can be intentionally retired.
- **Cutover strategy:** one-time import, delta sync, content freeze, incremental rollout, or parallel publishing.
## Migration Phases
1. **Inventory:** enumerate source types, fields, volumes, relationships, assets, URLs, locales, and data quality issues.
2. **Map:** decide Sanity document types, object types, references, Portable Text fields, asset fields, localization strategy, and migration metadata.
3. **Extract:** pull source data into raw JSON/XML/CSV files and keep them as repeatable snapshots.
4. **Transform:** convert raw source records into Sanity-shaped documents with deterministic IDs and explicit quality logging.
5. **Load:** import documents and assets using the CLI, migration tooling, or client mutations depending on volume and workflow.
6. **Validate:** compare counts, references, Portable Text shape, asset availability, required fields, frontend rendering, redirects, and editor usability.
7. **Cut over:** rerun against the latest source content, apply redirects, monitor broken links, and keep legacy source IDs for debugging.
## Agent Workflow
For agent-assisted migrations, require one human checkpoint before anything writes to Sanity:
1. Read the source export/API shape and the target Sanity project.
2. Print a migration plan: content types, fields, relationships, assets, locales, draft/status handling, and proposed Sanity types.
3. Flag judgment calls for review, such as page-shaped content, free-text taxonomies, rich text blocks, and localization strategy.
4. After approval, generate schemas, transform scripts, import scripts, config, and validation scripts.
5. Stop before running destructive or high-volume writes. The user should review the generated files and choose when to run them.
The useful AI pattern is "analyze, plan, generate, then let the user run," not "silently migrate everything."
## Recommended Project Layout
For implementation work, keep migration artifacts out of the application root unless the repo already has a convention:
```text
migration/
extracted/ # raw source snapshots, usually gitignored
transformed/ # generated Sanity-shaped JSON/NDJSON, usually gitignored
reports/ # counts, quality issues, skipped records, validation output
scripts/
extract.ts
transform.ts
validate.ts
import.ndjson
```
Make generated data reproducible. Commit migration scripts and mapping docs; gitignore large exports, credentials, raw customer data, and generated imports unless the project explicitly wants fixtures.
## Modeling Guidance
Model content by meaning, not by legacy storage or frontend layout.
- Split generic page/template records into semantic types when the page represents a real entity: `person`, `location`, `event`, `product`, `caseStudy`, `article`.
- Consolidate near-duplicates such as `Author`, `Staff`, `Presenter`, and `Expert` when they represent the same real-world entity.
- Use references for reusable or independently managed content: authors, people, products, categories, tags, companies, reusable testimonials, shared CTAs.
- Use embedded objects for content that only belongs inside one document: SEO metadata, page-specific hero content, one-off sections, migration metadata.
- Preserve source IDs and legacy URLs in a `migration` or `migrationMetadata` object unless the project already has a standard field shape.
- Add schema fields before migration code depends on them, then run schema extract and TypeGen if the project uses TypeScript.
## Extraction Patterns
Prefer the most structured source available:
- **Repository access:** read schemas, component definitions, and route/query code to prove completeness.
- **Admin or Local API:** best for drafts, hidden fields, private entries, full locale data, and complete relationship expansion.
- **Public API:** good for published content and fast prototypes, but document blind spots.
- **Export files:** reliable snapshots for repeatable transforms; inspect parser output carefully.
- **Database dumps:** complete but usually more work because relationships and media often require manual joins.
- **Static HTML or crawling:** viable fallback when no structured export exists, but plan for lower fidelity and manual review.
Prefer an official bulk export when it exists. It is usually safer than hand-crafting API calls because it is more likely to include drafts, scheduled content, assets, field definitions, and metadata. If required access or credentials are missing, stop and ask for read-only credentials or an export file rather than guessing.
Always snapshot raw extraction results before transformation. This makes the slowest part of migration, transform iteration, offline and repeatable.
## Transformation Patterns
Make transforms deterministic:
- Derive `_id` from source IDs, paths, slugs, locale IDs, or stable content hashes.
- Use stable `_key` values for generated arrays when rerun stability matters.
- Leave empty fields unset. Avoid `null`, empty objects, raw parser artifacts, and `"[object Object]"`.
- Keep raw source body fields temporarily only when they help verify rich text fidelity; hide or remove them after sign-off.
- Create lookup maps for every referenced type before transforming documents that reference them.
- Track quality issues per source record: missing required data, unresolved references, failed asset lookup, rich text conversion warnings, unsupported blocks, duplicate slugs.
Rich text is usually the long pole. Pick the converter by source format:
- Markdown: use `@portabletext/markdown`.
- Markdown that must be constrained to the Sanity schema: use `@portabletext/sanity-bridge`.
- Clean HTML: use `@portabletext/block-tools` with `JSDOM` and schema-aware custom deserializers.
- CMS-specific block JSON: map structurally to Portable Text or custom objects.
- Messy page-builder HTML: inspect real samples, define custom objects only for patterns that are consistent enough to preserve.
## Import Patterns
Choose the write path by migration size and repeatability:
- **NDJSON + `sanity dataset import`:** best for large initial loads and export-file migrations. Supports `_sanityAsset` directives and `--replace`.
- **`sanity migration`:** good for reproducible scripted imports inside a Studio project, dry runs, and batched mutations.
- **`sanity exec` or custom scripts with `@sanity/client`:** good for custom extraction/import loops, incremental syncs, or complex asset upload flows.
- **Sanity MCP/content tools:** good for small targeted operations, inspection, and patches. Avoid them for bulk content loads when a script or NDJSON import is more reliable.
Write order:
1. Assets or asset manifests.
2. Shared reference documents: authors, people, companies, categories, tags, products.
3. Primary content documents.
4. Translation metadata, redirect documents, and post-import relationship fixes.
For relationship-heavy migrations, use a Sanity-safe multi-pass import:
1. Upload assets and build source asset ID -> Sanity asset ID maps.
2. Promote reusable string lists, tags, categories, authors, products, or other shared values into reference documents.
3. Create primary documents with deterministic IDs and scalar fields.
4. Link references after every target document ID is known, either by emitting deterministic refs in NDJSON or by running a patch pass.
This pattern applies to Sanity because references are just document IDs. It is especially useful when the source stores relationships as nested objects, links, string arrays, or IDs that need lookup tables before they can become Sanity references.
## NDJSON and Asset Directives
For bulk imports, generate one valid JSON document per line. Every document needs `_type`; `_id` is optional but should be set for rerunnable migrations.
Use `_sanityAsset` where an asset reference would normally go:
```json
{"_id":"post-123","_type":"post","mainImage":{"_type":"image","_sanityAsset":"image@https://example.com/original.jpg"}}
```
Rules:
- Use `image@...` for images and `file@...` for files.
- Prefer the largest available original asset. Do not import multiple resized variants of the same source image.
- For local files, use absolute `file:///...` URIs or package the NDJSON and assets into a `.tar`, `.tar.gz`, or `.tgz`.
- During CLI import, Sanity temporarily imports references as weak and strengthens them after all documents are present; this is why `_updatedAt` can change for documents with references.
- Use `--replace` for idempotent reruns, `--missing` when only filling gaps, and `--allow-failing-assets` only when missing assets should be logged for later cleanup.
- Disable or pause webhooks that would be triggered by high-volume imports.
When using `@sanity/client` instead of CLI import:
- Use low-concurrency queues and batched transactions.
- Keep mutation payloads below API limits.
- Prefer mutation visibility `deferred` for large imports when immediate queryability is unnecessary.
- Use `_weak: true` in JSON references when creating references before targets exist. In schema definitions the property is `weak`; in client JSON the property is `_weak`.
## Validation and Cutover
Validate before declaring the migration ready:
- Compare source and Sanity counts by content type, locale, and status.
- Remember schema validation rules run in Studio, not automatically on API/client mutations. Validate in transform code before import.
- Spot-check representative documents from each type, including old, new, long, short, media-heavy, and locale variants.
- Confirm Portable Text fields are arrays of blocks, not raw HTML strings.
- Confirm all references have non-empty `_ref` values and the targets exist.
- Confirm images/files are Sanity assets or Media Library assets, with alt text and captions where available.
- Run `npx sanity@latest documents validate` when the destination schema is available.
- Crawl the legacy site and the new frontend to verify route coverage, redirects, canonical URLs, metadata, and broken links.
- For high-value or high-volume pages, run visual regression or screenshot checks against representative legacy and migrated pages.
- Keep a migration report with skipped content, known cleanup tasks, and fields that require editorial review.
## Sanity Implementation Guardrails
- Schema validation rules run in Studio and document validation commands, not automatically on API/client writes. Validate transformed documents before import.
- Use `defineType`, `defineField`, and `defineArrayMember` when creating schema files.
- Prefer image fields with `hotspot: true` and an `alt` field when editorial images need cropping or accessibility metadata.
- Use `defineQuery` for GROQ queries when the project uses TypeGen.
- Run schema extraction and TypeGen after schema/query changes when TypeScript types are part of the project.
- Deploy or apply schema changes before using MCP/content tools against the target dataset.
- Keep migration metadata small and useful: source system, source ID, source type, legacy URL, migrated timestamp, and quality flags.
references/markdown.md
# Markdown to Sanity
## What to Determine First
Before writing migration code, determine:
- Source shape: Markdown files, MDX, GitHub README-style content, static-site content, AI-generated Markdown, or Markdown exported from a CMS.
- File count, directory structure, and whether paths encode slugs, dates, sections, or locales.
- Frontmatter fields and formats.
- Markdown extensions: GFM tables, callouts, directives, footnotes, raw HTML, code fences, embeds, local images, and MDX JSX.
- Target Sanity document type and Portable Text field name.
- Allowed Portable Text styles, lists, decorators, annotations, and custom object types.
- Whether tags, authors, categories, and images are references, inline fields, or separate documents.
## Default Conversion Path
Use direct Markdown -> Portable Text conversion with `@portabletext/markdown`.
```bash
npm install @portabletext/markdown
```
```ts
import {markdownToPortableText} from '@portabletext/markdown'
const body = markdownToPortableText(markdown)
```
Do not call Markdown -> HTML -> `htmlToBlocks` "direct conversion." That is an HTML fallback path. Use it only when the Markdown pipeline cannot handle source-specific syntax that is easier to normalize as HTML.
## Schema-Constrained Conversion
For a Sanity block array schema, convert it with `@portabletext/sanity-bridge`:
```bash
npm install @portabletext/markdown @portabletext/sanity-bridge
```
```ts
import {markdownToPortableText} from '@portabletext/markdown'
import {sanitySchemaToPortableTextSchema} from '@portabletext/sanity-bridge'
const schema = sanitySchemaToPortableTextSchema(sanityBlockArraySchema)
const body = markdownToPortableText(markdown, {schema})
```
Use custom matchers when Markdown syntax must become specific Sanity objects:
```ts
const body = markdownToPortableText(markdown, {
schema,
types: {
table: ({context, value}) => ({
_type: 'table',
_key: context.keyGenerator(),
rows: value.rows,
headerRows: value.headerRows,
}),
},
})
```
Only emit custom types that exist in the target schema.
## Frontmatter Mapping
Use a frontmatter parser such as `gray-matter`:
```bash
npm install gray-matter
```
Map common fields deliberately:
- `title` -> document title.
- `slug` or file path -> `slug.current`.
- `date`, `published`, `updated` -> explicit datetime fields such as `publishedAt`.
- `author` -> reference, string, or author document depending on target schema.
- `tags` and `categories` -> reference documents when they power filtering/navigation.
- `description`, `canonical`, `ogImage` -> SEO object if the target schema has one.
- Source file path -> migration metadata for debugging.
Do not put frontmatter into the Portable Text body unless it is actual editorial content.
## Asset Handling
Markdown images need explicit handling:
- Remote image: use `_sanityAsset: "image@https://..."` in NDJSON or upload through the client.
- Local image: use an absolute `file:///...` URI in NDJSON, package assets into a tarball, or upload through the client.
- Prefer the largest original image, not resized derivatives.
- Preserve alt text from `` and title text when present.
- Rewrite image paths before conversion if Markdown uses relative paths.
- Log unresolved images; do not silently keep broken local paths.
If images should remain inline inside Portable Text, ensure the Portable Text schema allows an image object. If images belong in a separate field such as `mainImage`, extract them before conversion.
## Document IDs and Import
Use stable IDs:
- File path slug: `post-my-folder-my-file`.
- Frontmatter ID: `post-<sourceId>`.
- Hash only when no stable source ID/path exists.
For bulk imports, prefer NDJSON:
```json
{"_id":"post-example","_type":"post","title":"Example","body":[{"_type":"block","_key":"a","style":"normal","children":[{"_type":"span","_key":"b","text":"Hello","marks":[]}],"markDefs":[]}]}
```
Then import with:
```bash
npx sanity dataset import import.ndjson <dataset> --replace
```
Use client `createOrReplace` for smaller imports or incremental syncs.
## Keep Markdown Native
Only keep Markdown native when the project intentionally wants Markdown editing/rendering instead of Portable Text querying and structured blocks.
If keeping Markdown native, use a Markdown field/plugin and document the tradeoff: simpler migration and editing for Markdown-first teams, but weaker structured querying, references, annotations, and block-level content reuse.
## Validation Checklist
- Confirm every document has `_id`, `_type`, title/slug where required, and a Portable Text array.
- Confirm no body field is a raw Markdown string unless the target intentionally stores native Markdown.
- Confirm all Portable Text custom `_type` values exist in the schema.
- Confirm all image references resolve to remote URLs, absolute local file URLs, or uploaded Sanity asset IDs.
- Confirm frontmatter dates parse to valid ISO datetime strings.
- Confirm tags/authors/categories resolve according to the target model.
- Confirm rerunning the transform produces the same IDs.
- Confirm MDX JSX is mapped to Sanity objects, stripped, or logged for manual cleanup.
references/payload.md
# Payload to Sanity
## What to Determine First
Before writing migration code, determine:
- Payload version and whether the app is Payload standalone or integrated with Next.js.
- Whether repository access is available. Payload config is the best source of truth for collections, globals, fields, uploads, localization, access control, drafts, versions, and hooks.
- Access level: Local API, REST API, GraphQL API, database dump, or the official import/export plugin.
- Which collections, globals, upload collections, drafts, versions, localized fields, and hidden fields are in scope.
- Which rich text editor is used, such as Lexical or a legacy editor, and whether custom blocks are present.
- Which database and upload storage adapter are used.
## Extraction Paths
Prefer source paths in this order:
- **Repository + Local API:** best path when available. Import Payload with `getPayload({ config })`, then use `payload.find`, `payload.findGlobal`, and version APIs from a script running in the project context.
- **Import/export plugin:** official `@payloadcms/plugin-import-export` can export collection data as JSON or CSV. Prefer JSON for migration because it preserves nested objects, arrays, rich text structures, relationship references, and native field shapes.
- **REST API:** every collection and global has generated REST endpoints under the API route prefix, usually `/api`. Use `depth`, `locale`, `fallback-locale`, `select`, `populate`, `limit`, `page`, `sort`, and `where`.
- **GraphQL API:** useful if enabled and if the schema exposes all needed fields.
- **Database dump:** complete but usually lower-level than Payload's APIs. Use when the app cannot run or when API access is insufficient.
The Local API has server-only options that are useful for migration: `depth`, `locale`, `fallbackLocale`, `select`, `populate`, `overrideAccess`, `showHiddenFields`, and `pagination: false`.
Important: Local API operations skip access control by default. Do not export auth collections, user secrets, password hashes, tokens, or hidden operational fields unless the migration explicitly requires them and the user has approved the scope.
## Import/Export Plugin Details
The official `@payloadcms/plugin-import-export` can export collection data as CSV or JSON. Use JSON for Sanity migrations unless there is a strong reason to flatten to CSV.
Export routes/options to know:
- Direct download streams a file from `POST /api/exports/download`.
- Saved exports create an upload document in the `exports` collection.
- Local API can create an export document, for example with collection slug and `format: "json"`.
- Jobs Queue can run `createCollectionExport`; ensure a job runner exists or set synchronous behavior for small/test exports.
- Export parameters include `locale` and `drafts`; `locale: "all"` is useful for multi-locale export, and `drafts: true` includes draft versions for collections with drafts enabled.
JSON export preserves nested structures, rich text, relationship references, and native field shapes. CSV export flattens nested fields with underscore notation and coerces values, so it is worse for rich text and relationship-heavy migrations.
## Local API Examples
Initialize Payload in a script:
```ts
import {getPayload} from 'payload'
import config from '@payload-config'
const payload = await getPayload({config})
```
Extract a collection:
```ts
const result = await payload.find({
collection: 'pages',
depth: 0,
page: 1,
limit: 100,
locale: 'en',
fallbackLocale: false,
showHiddenFields: true,
})
```
Extract a global:
```ts
const header = await payload.findGlobal({
slug: 'header',
depth: 0,
locale: 'en',
fallbackLocale: false,
showHiddenFields: true,
})
```
Use `depth: 0` when building deterministic Sanity references from relationship fields; populated relationship objects are useful for inspection but can hide the source ID shape and create circular/large payloads.
For draft-enabled collections, `draft: true` returns the latest version, which may be a draft. It is not "drafts only." Decide whether to migrate that latest draft as `drafts.<id>`, published content as `<id>`, or both.
## Mapping to Sanity
- Payload collections usually become Sanity document types.
- Payload globals usually become Sanity singleton documents with fixed IDs.
- Payload blocks map to Sanity named object types, page builder objects, or Portable Text custom objects.
- Payload relationships map to Sanity references.
- Payload uploads map to Sanity image/file fields and assets.
- Payload localized fields can map to Sanity field-level localization or document-level localization depending on the target workflow.
- Payload drafts and versions require an explicit decision: migrate only published content, migrate draft variants as Sanity drafts, or archive version history outside Sanity.
Use Payload config files to distinguish actual content architecture from incidental database shape.
## Transformation Notes
- Use collection slug and source document ID for deterministic Sanity IDs, such as `post-<payloadId>` or `<collectionSlug>-<payloadId>`.
- For localized document-level migrations, prefer IDs such as `payload.<collection>.<id>.<locale>` or the project's existing translation ID convention.
- Build maps for relationships and uploads before transforming documents that reference them.
- Export one locale at a time or export all locales and normalize into the chosen Sanity localization model.
- For globals, assign fixed IDs such as `global-header`, `settings`, or the existing project convention.
- For uploads, extract original file URL/path, filename, mime type, alt text, dimensions, and any focal/crop metadata if present.
- For rich text, map the editor's JSON tree structurally. Do not stringify Lexical or block JSON into Sanity fields.
- If using the import/export plugin, configure export format as JSON and include locale/draft options when needed.
## Gotchas
- REST and GraphQL responses are governed by access control and selected fields. Local API with `overrideAccess` and `showHiddenFields` is better for complete migration snapshots.
- Hooks can mutate values during API reads or writes. Decide whether to run through Payload APIs for normalized behavior or read raw database rows for historical fidelity.
- Uploads may be stored locally, in S3, Cloudinary, or another adapter. API documents may not contain directly fetchable public URLs.
- `depth` controls relationship/upload population. Too shallow loses data; too deep can make huge payloads or circular shapes.
- Draft and version data are separate concerns. The import/export plugin can include drafts for draft-enabled collections; version history may need separate extraction through version APIs if it is in scope.
- Localized fields can be exported as a single locale or all locales. Do not accidentally collapse fallback values into translations.
- JSON export preserves nested structures; CSV flattens and coerces values and is less suitable for rich migration transforms.
## Validation Checklist
- Compare collection, global, upload, locale, draft, and version counts against Payload admin/API/database.
- Confirm every collection and global in the Payload config is mapped, skipped, or intentionally consolidated.
- Confirm relationships resolve to deterministic Sanity IDs.
- Confirm upload URLs or files are accessible to the Sanity import/upload process.
- Spot-check rich text with custom blocks, links, embeds, and nested structures.
- Confirm localized content preserves missing translations and fallback behavior according to the migration plan.
- Confirm singleton/global documents have stable IDs and frontend queries can retrieve them.
references/strapi.md
# Strapi to Sanity
## What to Determine First
Before writing migration code, determine:
- Strapi version, especially v4 vs v5.
- Access level: public REST, authenticated REST, GraphQL, Document Service / Entity Service, repository access, admin token, or database.
- Whether drafts, private fields, unpublished entries, and hidden types are in scope.
- Whether i18n is enabled and how locales are represented.
- Rich text/body format: Strapi blocks, Markdown, HTML, CKEditor, or custom JSON.
- Dynamic zones and the complete set of `__component` values.
- Asset provider: local disk, S3, Cloudinary, Strapi Cloud media CDN, or another provider.
## Extraction Paths
Access changes extraction completeness more than downstream complexity:
| Access | Best use | Main risk |
| --- | --- | --- |
| Repository | Prove schemas, components, dynamic zones | Does not contain content values |
| Public REST | Fast published-content extraction | Omits drafts/private fields and cannot prove completeness |
| Admin/authenticated API | Drafts, unpublished entries, private fields | Requires approved credentials |
| GraphQL | Typed extraction when enabled | Introspection may be disabled |
| Database | Completeness checks, hidden data | Requires manual joins through relation/morph tables |
REST with `populate` is often better than raw database extraction because Strapi returns joined relationships and components.
Snapshot all extracted records before transformation. For REST extraction, explicitly request deep population for nested components and dynamic zones.
## Schema Discovery Commands
When repository or admin access is available, prove the schema before sampling content:
```bash
# Content type schemas
ls src/api/*/content-types/*/schema.json
# Component schemas
ls src/components/*/*.json
# Strapi configuration snapshot
npx strapi configuration:dump --file strapi-config.json
```
With admin API access, inspect the content-type builder:
```bash
curl "http://localhost:1337/admin/content-type-builder/content-types" \
-H "Authorization: Bearer <admin-token>"
```
Each Strapi schema file contains `info`, `attributes`, and `options`. Use these to find field types, validations, draft/publish settings, timestamps, components, dynamic zones, media, and relations.
## Mapping to Sanity
- Strapi collection types become Sanity document types when they are independently managed.
- Strapi single types usually become Sanity singleton documents with fixed IDs.
- Strapi components become Sanity object types.
- Strapi dynamic zones become arrays of named object types, one Sanity `_type` per `__component`.
- Strapi relations become Sanity references.
- Strapi media objects become Sanity image/file fields and assets.
- Strapi v5 `documentId` is the best stable source key. Use it for deterministic Sanity IDs and locale grouping.
- Strapi i18n variants usually map well to document-level localization with `language` fields and translation metadata.
For localized Strapi v5 content, a useful deterministic ID pattern is `<type>.<documentId>.<locale>`.
Field mapping defaults:
- `string` maps to `string`.
- `text` maps to `text`.
- `richtext` maps to Portable Text.
- `number`, `integer`, `float`, and `decimal` map to `number`.
- `date`, `datetime`, and `time` map to the closest Sanity date/datetime strategy the frontend expects.
- `media` maps to `image`, `file`, or arrays of those.
- `relation` maps to `reference` or an array of references.
- `component` maps to a named object type.
- `dynamiczone` maps to an array of union object types.
## Transformation Notes
- Enumerate `__component` values across all records, not a sample.
- Derive IDs from `documentId` in Strapi v5. For v4, choose a stable fallback such as content type plus numeric ID or slug, and document the risk.
- Build maps for relationships and media before transforming documents that reference them.
- Deduplicate assets by media `hash` when available.
- Keep the raw source body temporarily until rich text fidelity is verified.
- Use weak references or deterministic IDs during bulk import so order does not create duplicate referenced documents.
Body conversion depends on editor format:
- **Strapi blocks:** map structurally to Portable Text or custom Portable Text objects.
- **Markdown:** use `@portabletext/markdown`, with custom handling for GFM tables, fenced code, and inline images.
- **HTML / CKEditor:** use `@portabletext/block-tools` with `JSDOM` or a custom converter if the HTML is inconsistent.
## Asset Pipeline
1. Extract every Strapi media object from fields, dynamic zones, and rich text bodies.
2. Dedupe by `hash` when present; otherwise use source URL/path plus filename.
3. Download from the configured provider: local uploads, S3, Cloudinary, Strapi Cloud media CDN, or another adapter.
4. Upload once to Sanity and store `oldUrl/hash -> asset _id`.
5. Rewrite image/file fields and inline body media to Sanity asset references.
6. Log missing or private media URLs as quality issues, not silent omissions.
## Gotchas
- Public REST returns published content only. Draft state requires admin/API/database access.
- Strapi v5 `documentId` is stable across locales and draft/published variants; numeric IDs are less useful for cross-locale identity.
- `populate` depth limits can hide nested dynamic-zone data. Test deeply nested examples.
- The API can only infer shapes from returned content; repo access proves the full schema.
- Components can drift between object and array shapes depending on configuration and historical data.
- GraphQL may be disabled or too restricted for migration use.
- Some pages may be hardcoded in the frontend rather than Strapi-driven. Audit frontend routes before assuming Strapi contains every page.
- If only frontend code is available, inspect API calls, `populate` parameters, component usage, custom plugin fields, and expected response shapes.
## Validation Checklist
- Compare collection, single type, locale, and asset counts with Strapi admin/API/database counts.
- Confirm every component and dynamic-zone `__component` value is mapped or intentionally skipped.
- Confirm draft/unpublished scope is explicitly handled.
- Spot-check content with deepest nested dynamic zones.
- Confirm relationship refs use deterministic Sanity IDs and resolve.
- Confirm media deduplication and all migrated assets render in Studio and the frontend.
references/webflow.md
# Webflow to Sanity
## What to Determine First
Before writing migration code, determine:
- Which content lives in Webflow CMS collections and which content exists only in static pages.
- Whether CSV export, Webflow Data API access, or static HTML export is available.
- Whether the site uses Webflow Components, component variants, symbols, or repeated section patterns.
- Which collection references and multi-references exist.
- Whether assets are public on Webflow CDN and must be moved before cutover.
- Which slugs and routes need to stay stable for SEO.
## Extraction Paths
Use different extraction paths for CMS content and static pages:
- **CMS collections:** export each collection as CSV or use the Webflow API if available. CSV is often sufficient and easy to audit.
- **References:** Webflow reference fields commonly export IDs. Build lookup maps from item IDs to target Sanity document IDs.
- **Static pages:** export HTML and analyze page sections separately from CMS collection data.
- **Components:** inventory components in the Webflow designer before relying on HTML export; exported HTML does not preserve component intent.
- **Assets:** collect asset URLs from CSV fields, rich text HTML, static page HTML, CSS, and Open Graph metadata.
Do not reorganize a live Webflow CMS just to make export cleaner. Audit and clean exported files instead.
## Mapping to Sanity
- Webflow collections usually become Sanity document types.
- Webflow references and multi-references become Sanity references.
- Webflow rich text fields become Portable Text.
- Webflow image/file fields become Sanity image/file fields.
- Static pages can become singleton documents, semantic document types, or page documents with a page builder array.
- Webflow components and repeated section patterns can inform page builder object types, but avoid copying visual class names into schema names.
- Use singleton documents with fixed IDs for unique pages that do not belong in a collection.
## Transformation Notes
- Normalize CSV values before import: empty columns, inconsistent taxonomy spellings, boolean-like strings, and duplicate slugs.
- Build a Webflow item ID to Sanity ID lookup before resolving references.
- Convert rich text HTML with explicit image/link handling.
- Upload or import assets before document import, then replace Webflow CDN URLs with Sanity asset references.
- For static pages, identify section patterns first, then design Sanity objects. Ask what fields editors need to manage, not what CSS classes exist.
- Use variant fields only when variants are editorially meaningful and stable, such as `tone`, `emphasis`, or `layoutIntent`.
## Import Order
1. Export and clean CMS collection CSVs.
2. Import leaf collections first: categories, tags, authors, locations, or other records referenced by others.
3. Import dependent collections using the Webflow item ID -> Sanity document ID lookup.
4. Migrate assets and replace Webflow CDN URLs in rich text and image/file fields.
5. Analyze static HTML and component inventory, then migrate singleton pages or page builder documents.
## Static Page and Page Builder Workflow
Before analyzing exported static HTML, inventory Webflow Components in the designer:
- Component name.
- Variants.
- Pages where each component appears.
- Whether the component represents reusable content, layout only, or a one-off section.
This inventory is not recoverable from exported HTML. Use it alongside static page HTML when designing Sanity page builder objects.
Analyze pages in this order:
1. Identify distinct section patterns before writing schemas.
2. Group visually similar structures and note where variants differ.
3. Decide whether each pattern is a page builder object, a singleton page field, a reference to a reusable document, or frontend-only layout.
4. Add variant fields only for stable editorial choices; do not encode CSS classes as schema names.
Singleton examples: `homePage`, `aboutPage`, `pricingPage`, `contactPage`.
A useful analysis prompt for agents:
```text
Identify all distinct content section patterns in this Webflow HTML. Group visually similar structures together, note where variants differ, and propose semantic Sanity fields for each section. Do not copy CSS class names into schema names.
```
## Cutover
Build a URL map before launch:
- Export/crawl every old Webflow route, including CMS item pages and static pages.
- Map unchanged slugs 1:1 where possible.
- Create 301 redirects for renamed, consolidated, or retired pages.
- Check sitemap, canonical URLs, Open Graph images, and high-value inbound links.
Missing redirects are the most common Webflow-to-Sanity SEO regression.
## Gotchas
- Webflow HTML export loses component metadata, variants, and designer intent.
- Webflow CMS reference fields can export opaque IDs; do not assume names or slugs are enough for reliable joins.
- Rich text exports as HTML strings and may include embedded Webflow-specific markup.
- Webflow-hosted assets can break after cutover. Do not leave production content dependent on Webflow CDN URLs.
- Webflow forms, interactions, memberships, ecommerce, and custom code often require separate frontend or service replacement.
- Static pages may contain content that should become structured documents rather than page builder sections.
## Validation Checklist
- Compare CSV row counts to Webflow collection item counts.
- Confirm every reference and multi-reference field resolves to a Sanity reference.
- Spot-check rich text fields with links, images, lists, and embeds.
- Confirm all Webflow CDN asset URLs have Sanity replacements or intentional external handling.
- Review the component inventory with a human before finalizing page builder schemas.
- Crawl old Webflow URLs and verify new pages or 301 redirects.
references/wordpress.md
# WordPress to Sanity
## What to Determine First
Before writing migration code, determine:
- Source format: REST API, WXR/XML export, WPGraphQL, WP-CLI, database dump, static HTML, or individual page-builder exports.
- Whether the site uses Gutenberg blocks, Classic editor HTML, Advanced Custom Fields, custom post types, custom taxonomies, multilingual plugins, WooCommerce, Yoast, or page builders.
- Whether drafts, private posts, scheduled posts, revisions, menus, comments, and users are in scope.
- Whether media files are public, behind auth, on WordPress uploads, on a CDN, or in a third-party DAM.
- Whether WordPress IDs can be preserved in deterministic Sanity IDs.
## Extraction Paths
Choose the extraction route by available access:
- **WordPress REST API:** good for live, paginated, public content. Use `/wp-json/wp/v2`, `per_page=100`, and response headers such as `x-wp-total` and `x-wp-totalpages` for counts.
- **Authenticated REST API:** needed for `context=edit`, raw Gutenberg content, drafts, private fields, and some plugin data. Use application passwords or another approved auth flow; store credentials in environment variables.
- **WXR/XML export:** common for handoff scenarios and good for a repeatable file-based migration. Parse namespaced fields and unwrap CDATA or parser objects before transformation.
- **WPGraphQL:** useful when installed and configured to expose the needed fields.
- **WP-CLI/database:** useful with direct server access, custom fields, and completeness checks.
- **Static HTML:** fallback only when structured access is unavailable.
If a WXR/XML file is present and complete, default to parsing it rather than requiring WP-CLI access.
REST URLs:
- Standard site: `https://<domain>/wp-json/wp/v2`.
- Multisite: `https://<domain>/<site-name>/wp-json/wp/v2`.
- Count check: `curl -sI "<base>/posts?per_page=100"`.
Authenticated raw content example:
```bash
curl -u "$WP_USER:$WP_APP_PASSWORD" \
"<base>/posts?context=edit&per_page=100"
```
If REST is blocked by Cloudflare or security plugins, use one of these before scraping rendered HTML: ask for a WXR export, run WordPress locally with a production database copy, have the customer save JSON from an authenticated browser session, or pass a valid `cf_clearance` cookie only with explicit approval.
## Schema Discovery
With WordPress access, export schema signals before modeling:
```bash
wp post-type list --format=json > post-types.json
wp taxonomy list --format=json > taxonomies.json
wp acf export --format=json > acf-fields.json
```
If WP-CLI is unavailable, inspect:
- `register_post_type()` calls in themes/plugins.
- `register_taxonomy()` calls.
- ACF `get_field()` usage and exported field groups.
- Template files such as `single-*.php`, `archive-*.php`, and page templates.
- `wp_posts`, `wp_postmeta`, and `wp_term_taxonomy` when database access exists.
ACF defaults:
- Text/Textarea maps to `string` or `text`.
- WYSIWYG maps to Portable Text.
- Image maps to `image`.
- Gallery maps to an array of images.
- Repeater maps to an array of objects.
- Flexible Content maps to an array of union object types.
- Relationship maps to a reference or array of references.
## Mapping to Sanity
- Posts and pages may become `post`, `page`, or more semantic document types such as `article`, `person`, `location`, `event`, or `caseStudy`.
- Custom post types usually deserve explicit Sanity document types, not a generic `postType` field.
- Users often become `author` or `person` documents. Treat them separately from Sanity project members.
- Categories, tags, and useful custom taxonomies should become reference documents when they power navigation, filtering, SEO, or editorial reuse.
- ACF field groups map to Sanity fields, objects, or references depending on reuse and lifecycle.
- Yoast and other SEO metadata can map to an `seo` object if the target project uses one.
- Menus can become navigation singleton documents or frontend configuration, depending on the target architecture.
Use page templates and custom post types as signals for content trapped in presentational structures.
## Transformation Notes
- Use deterministic IDs such as `post-123`, `page-123`, `author-41`, `category-news`, or a project-specific semantic prefix.
- Create authors and taxonomy documents before posts/pages that reference them.
- Build attachment maps before transforming content:
- `_thumbnail_id` postmeta points to an attachment post.
- Attachment records contain URLs and metadata such as alt text.
- Featured images should become Sanity image fields with `_sanityAsset` or uploaded asset references.
- Decode HTML entities in titles, excerpts, taxonomy names, and author names.
- Store editorial dates in explicit fields such as `publishedAt` and `modifiedAt`; do not rely on `_createdAt` / `_updatedAt` for source publication dates.
- Omit empty fields instead of storing empty CDATA objects, empty strings, or `null`.
- Keep a per-document quality log for missing authors, failed media, malformed HTML, unsupported blocks, duplicate slugs, and skipped post types.
## WXR/XML Field Map
When parsing WXR/XML, always unwrap parser output before writing Sanity documents. Prefer `fast-xml-parser` so CDATA is captured consistently:
```ts
import {XMLParser} from 'fast-xml-parser'
import {readFile} from 'node:fs/promises'
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
cdataPropName: '_cdata',
textNodeName: '_text',
})
const xml = await readFile('wordpress-export.xml', 'utf8')
const parsed = parser.parse(xml)
function text(value: unknown): string {
if (value == null) return ''
if (typeof value === 'string' || typeof value === 'number') return String(value)
if (Array.isArray(value)) return text(value[0])
if (typeof value === 'object') {
const record = value as Record<string, unknown>
return text(record._cdata ?? record._text)
}
return ''
}
```
Run every XML field through a helper like `text()` before writing Sanity documents. Never store raw parser objects.
Key WXR fields:
- `title` -> title.
- `wp:post_name` -> `slug.current`.
- `content:encoded` -> body or content source, converted to Portable Text.
- `excerpt:encoded` -> excerpt; omit when empty.
- `wp:post_date_gmt` -> `publishedAt` or equivalent source publication field.
- `dc:creator` -> author lookup key; prefer mapping to WordPress author ID when possible.
- `wp:post_type` -> content type routing.
- `wp:post_id` -> source ID for deterministic Sanity IDs.
- `wp:postmeta[]` -> ACF, `_thumbnail_id`, Yoast, Elementor, and other plugin data.
- `category[]` with `domain` attributes -> categories, tags, or custom taxonomies.
Build a flat postmeta map:
```ts
const meta: Record<string, string> = {}
for (const item of post['wp:postmeta'] || []) {
const key = text(item['wp:meta_key'])
const value = text(item['wp:meta_value'])
if (key) meta[key] = value
}
```
Featured image is not in the main post body. Build an attachment map from `wp:post_type === "attachment"` records, then resolve `meta["_thumbnail_id"]` to `{url, alt}` and set an image field with `_sanityAsset`.
Map common Yoast fields only when non-empty:
- `_yoast_wpseo_title` -> `seo.metaTitle`.
- `_yoast_wpseo_metadesc` -> `seo.metaDescription`.
- `_yoast_wpseo_focuskw` -> `seo.focusKeyword`.
- `_yoast_wpseo_meta-robots-noindex === "1"` -> `seo.noIndex`.
Also inspect Rank Math or other SEO plugin postmeta if present; do not assume Yoast is the only SEO source.
## Rich Text
WordPress body content is the highest-risk part of most migrations.
- **Rendered REST HTML (`content.rendered`):** convert to Portable Text with HTML tooling, but expect loss of block/editor structure.
- **Raw Gutenberg content (`content.raw` with `context=edit`):** parse with `@wordpress/block-serialization-default-parser`, then map each block type to Portable Text blocks or custom objects.
- **Classic editor or WXR HTML:** prefer a custom JSDOM traversal when the markup includes WordPress artifacts, shortcodes, inconsistent links, or legacy editor cruft. `@portabletext/block-tools` is acceptable only after testing on real posts and confirming markDefs and custom blocks are correct.
- **Shortcodes:** identify project-specific shortcodes before conversion. Some become custom objects; others can be stripped or logged for editorial cleanup.
- **Inline images:** resolve through media/attachment maps when possible; otherwise use `_sanityAsset` from the discovered URL and log unresolved metadata.
For links, remember that Portable Text annotations live in a block's `markDefs`; spans reference annotation keys through `marks`. The link mark definition belongs on the block, not the span.
Before running full conversion, test 3-5 real posts and confirm:
- The body is an array of Portable Text blocks, not a string.
- No raw HTML tags remain in text spans.
- Links have `markDefs` on the block.
- Empty blocks are filtered out.
- Unsupported shortcodes or custom blocks are logged.
Common Gutenberg mappings:
- `core/paragraph`, `core/heading`, `core/list`, `core/quote`: Portable Text blocks.
- `core/image`: image block with resolved attachment or `_sanityAsset`.
- `core/embed`: custom embed object or logged external embed.
- `core/columns` and `core/column`: custom Portable Text object or page builder object if layout matters.
- `core/button` / `core/buttons`: CTA object, link annotation, or page-level CTA based on reuse.
- `core/table`: custom table object; do not flatten unless table semantics are not needed.
- `core/code` / `core/preformatted`: code block object when code content matters.
## Elementor and Page Builders
Elementor stores structured page data in `_elementor_data`, not in normal REST `content.rendered`.
- Prefer Elementor template JSON exports from WordPress admin or WXR/database access to `_elementor_data`.
- The exported JSON has a root `content` array of sections, columns, containers, and widgets.
- Work at section/container level. Classify sections by widget mix and editorial purpose, then map to Sanity page builder objects.
- Use `widgetType` to identify content fields, but avoid creating one Sanity type per low-level widget.
- If only rendered HTML is available, treat it as a lossy fallback and plan human review.
- Rewrite private or environment-specific media URLs to public URLs before using `_sanityAsset`.
Common Elementor widget fields:
- `heading`: `settings.title`, `settings.header_size`.
- `text-editor`: `settings.editor` HTML.
- `image`: `settings.image` as object or JSON string with URL/ID/alt.
- `button`: `settings.text`, `settings.link`, `settings._css_classes`.
- `counter`: `settings.ending_number`, `settings.prefix`, `settings.suffix`, `settings.title`.
- `icon-list`: `settings.icon_list[]`.
- `html`: `settings.html`; inspect for forms, carousels, or embeds.
- `template`: `settings.template_id`; resolve global Elementor templates separately.
Other page builders such as Divi and Beaver Builder have similar risks: rendered HTML loses source structure and usually requires custom handling.
## Gotchas
- REST API access can be blocked by security plugins, Cloudflare, or custom hardening.
- WXR/XML parser output can wrap values in objects such as `_cdata`; unwrap every field before writing Sanity documents.
- `content.rendered` has shortcodes and filters already applied; `content.raw` requires auth but preserves Gutenberg block comments.
- ACF fields may not appear in REST unless configured.
- Author display names are fragile join keys. Prefer WordPress author IDs when available.
- Attachment URLs may point to staging domains, private CDN domains, or resized variants. Prefer the best available original asset URL.
- Multilingual plugins differ significantly; inspect the plugin-specific relationship data before choosing Sanity's localization shape.
## Validation Checklist
- Count documents by `wp:post_type` or REST endpoint and compare to Sanity document counts.
- Confirm skipped WordPress types are intentional, such as `attachment`, `nav_menu_item`, `wp_block`, or ACF definitions.
- Spot-check at least five rich text documents across old/new content and different editors.
- Search generated output for raw `<p`, `_cdata`, and `[object Object]`.
- Confirm featured images resolve from `_thumbnail_id` or another source-specific image field.
- Confirm author and taxonomy references point to existing documents.
- Confirm at least one document has a real image `_sanityAsset` directive or uploaded asset reference, not a TODO/null placeholder.
- Confirm the author pass uses WordPress author IDs or a documented fallback map, not display-name matching alone.
- Confirm Yoast/SEO objects are omitted when all source values are empty.
- Verify old URLs, slugs, canonical metadata, and redirects.