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.
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.
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.
.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.