Migrating from Sanity to MODX: A Complete Technical Guide
Step-by-step technical guide for migrating content from Sanity's JSON-based Content Lake to MODX Resources, covering Portable Text conversion, xPDO imports, and asset handling.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Migrating from Sanity to MODX: A Complete Technical Guide
Migrating from Sanity to MODX means moving from a headless, API-first CMS that stores content as structured JSON in a cloud-hosted Content Lake to a self-hosted PHP content management framework backed by MySQL. The two systems share almost no architectural DNA. There is no plugin, no official connector, and no drag-and-drop importer. The migration is entirely script-driven.
You will export NDJSON from Sanity, transform every document — including converting Portable Text to HTML — and write each record into MODX as a Resource with Template Variables using PHP and xPDO. This guide covers the full process: data model mapping, export, transformation, import, asset handling, reference resolution, and validation.
MODX version note: This guide targets both MODX Revolution 2.x and MODX 3.x. In MODX 2.x, the primary resource class is
modResourceand the ORM entry point is$modx. In MODX 3.x, classes are namespace-prefixed (e.g.,MODX\Revolution\modResource) and accessed via the same$modxinstance, butrequirepaths and bootstrap behavior differ slightly. All PHP examples in this guide use the 2.x class names; append the namespace prefix where required for MODX 3.x deployments.
How Sanity and MODX store content differently
Understanding the structural gap between these two platforms—a challenge common when moving from a headless CMS to a PHP/MySQL system—is the first prerequisite to planning a clean migration.
Sanity is a graph-like document store. Every piece of content — pages, authors, categories — is a flat JSON document with a unique _id and _type. Relationships use _ref pointers, allowing any document to link to any other without strict hierarchy. Rich text lives in Portable Text format, a JSON-based AST rather than HTML. Assets (images, files) are stored on Sanity's CDN at https://cdn.sanity.io/images/{projectId}/{dataset}/{assetId}-{dimensions}.{format} and referenced by asset document IDs. You query everything with GROQ or GraphQL.
MODX organizes content into Resources — database rows representing pages in a parent-child tree, each with standard fields like pagetitle, longtitle, alias, content, and publishedon. Custom fields are handled through Template Variables (TVs), which extend the default resource attributes. Presentation logic lives in Elements: Templates, Chunks, Snippets, and Plugins. MODX uses xPDO, its built-in ORM layer, to interact with a MySQL or MariaDB database. Raw SQL INSERT statements must never be used — they bypass MODX's caching mechanisms, fail to generate URI paths, and corrupt the hierarchical index.
Because of this architectural gap, you cannot pipe Sanity data directly into MODX. You must build a middleware process that reconstructs hierarchy, resolves references into integer IDs, and converts Portable Text into standard HTML.
| Sanity concept | MODX equivalent | Notes |
|---|---|---|
| Document type | Resource + Template | Each Sanity _type maps to a MODX Template defining which TVs are available |
| Portable Text field | content field (HTML) |
Requires conversion from JSON AST to HTML via @portabletext/to-html |
| Custom fields (string, number, date) | Template Variables | TVs are MODX's extensibility mechanism for custom resource fields |
Reference (_ref) |
Resource ID or TV value | No native graph references — store integer resource IDs or aliases |
| Array of objects | MIGX TV (JSON string) | MIGX stores complex array data as a JSON string in a single TV |
| Image/file asset | Media file on disk or Media Source | Assets must be downloaded and re-hosted; CDN URLs are project-scoped |
| GROQ query | Snippet + xPDO query | Server-side PHP replaces Sanity's query language |
| Slug field | alias field |
Used for URL generation in both systems |
Sanity field type → MODX TV type mapping
This table covers the most common decision point in schema translation. Use it to determine the correct MODX TV type for each Sanity field.
| Sanity field type | MODX TV type | Notes |
|---|---|---|
string |
Text | Single-line input |
text |
Textarea | Multi-line plain text |
number |
Text | MODX has no native numeric TV; validate in code |
boolean |
Checkbox | Returns 1 or 0 |
datetime |
Date | Store as Unix timestamp; set both publishedon and createdon |
array of strings |
Listbox (multi-select) | Serialize as comma-delimited or use MIGX |
array of objects |
MIGX TV (JSON string) | MIGX expects a JSON-encoded array; use json_encode() directly |
reference (_ref) |
Text or Number | Store the mapped MODX resource integer ID |
image |
Image TV | Store local file path after asset migration |
file |
File TV | Store local file path after asset migration |
block (Portable Text) |
Richtext / content field |
Convert to HTML; primary body goes in content, secondary blocks in Richtext TV |
slug |
alias field |
Used for URL generation natively |
object (nested) |
Multiple TVs or MIGX | Flatten simple objects to individual TVs; use MIGX for complex structures |
Step 1: Audit and map your Sanity content model
Before writing any migration code, catalog every document type, field, and reference in your Sanity project. This mapping determines where your headless data will live in MODX's relational database.
Export your dataset
Export your full dataset using the Sanity CLI:
sanity dataset export production ./sanity-export.tar.gzThis produces a .tar.gz archive containing an NDJSON file of all documents plus an images/ directory with all assets. The NDJSON file has one JSON object per line — every document, every asset metadata record, every draft.
For a more targeted export, use GROQ to pull specific document types:
sanity documents query '*[_type == "article"]' --dataset production > articles.ndjsonSanity exports include draft documents (prefixed with drafts. in the _id field). Filter these out during transformation unless you specifically want to migrate unpublished content.
Introspect your schema programmatically
For projects with more than a handful of document types, use the Sanity CLI schema extraction rather than auditing types manually:
sanity schema extract --enforceRequiredFields > schema.jsonThis produces a machine-readable JSON representation of every document type, field name, and field type in your studio. Feed it into a script to auto-generate the mapping spreadsheet rather than constructing it by hand. At scale, this step saves hours of audit work and eliminates transcription errors.
Content model audit checklist
Build a mapping document that answers these questions for every document type:
- Which MODX Template will this type use?
- Which Sanity fields map to standard MODX resource fields (
pagetitle,longtitle,description,alias,content)? - Which Sanity fields need Template Variables, and which TV type? (See table above.)
- Which fields contain Portable Text that needs HTML conversion?
- Which fields hold
_refreferences to other documents? - Which fields hold image or file asset references?
- Are there any
arrayof object fields that require MIGX?
MODX uses integer IDs for resources, while Sanity uses UUID strings. You must maintain a mapping table during the migration to translate Sanity _id strings to newly generated MODX id integers. Store this mapping persistently (e.g., in a JSON file or a temporary database table) so the second import pass can resolve references.
Alias collision detection
Two Sanity documents can produce the same MODX alias after slugification — for example, "My Post!" and "my-post" both become my-post. This is a real production failure mode that silently drops records in naive import scripts.
Add a collision check before every save() call and implement a deduplication strategy:
function deduplicateAlias(string $alias, array &$usedAliases): string {
$base = $alias;
$counter = 2;
while (in_array($alias, $usedAliases)) {
$alias = $base . '-' . $counter;
$counter++;
}
$usedAliases[] = $alias;
return $alias;
}Log every collision so you can manually review whether the two source documents should have distinct aliases.
Step 2: Set up the MODX target environment
Prepare your MODX instance before running any import scripts. Templates and Template Variables must exist in advance so the import script can reference them by ID.
Create Templates
For each Sanity document type you're migrating, create a corresponding MODX Template. A Sanity article type becomes an "Article" Template; a page type becomes a "Page" Template. Define each Template's HTML structure with [[*content]] for the main body and TV tags like [[*author_name]] for custom fields.
Create Template Variables
For each Sanity custom field that doesn't map to a built-in MODX resource field, create a TV and bind it to the appropriate Template. Reference the field type mapping table above to choose the correct TV type.
You can create TVs programmatically when you have many to create:
$tv = $modx->newObject('modTemplateVar');
$tv->set('name', 'author_name');
$tv->set('caption', 'Author Name');
$tv->set('type', 'text');
$tv->set('default_text', '');
if ($tv->save()) {
$tvt = $modx->newObject('modTemplateVarTemplate');
$tvt->set('tmplvarid', $tv->get('id'));
$tvt->set('templateid', $articleTemplateId);
$tvt->save();
}MODX 3.x: Replace
'modTemplateVar'with'MODX\Revolution\modTemplateVar'and'modTemplateVarTemplate'with'MODX\Revolution\modTemplateVarTemplate'.
Step 3: Validate transformed data before import
Insert a validation pass between your transformation script and the import script. Catching structural errors before any records are written prevents partial-import states that are expensive to debug and clean up.
Minimum validation checks:
- All required fields (
pagetitle,alias,template) are non-null and non-empty. - All
_refvalues in the transformed dataset exist as keys in the ID map (no dangling references). - No two records share the same
aliasunder the sameparentID. - Converted HTML is well-formed (run through a DOM parser, not a regex check).
- All referenced asset paths exist on disk before the import writes them to TV values.
// Node.js validation before writing transformed-articles.json
function validateDoc(doc, index) {
const errors = [];
if (!doc.pagetitle) errors.push(`[${index}] Missing pagetitle`);
if (!doc.alias) errors.push(`[${index}] Missing alias`);
if (doc.alias && !/^[a-z0-9-]+$/.test(doc.alias)) {
errors.push(`[${index}] Invalid alias characters: ${doc.alias}`);
}
if (doc.tv_author_ref && !knownSanityIds.has(doc.tv_author_ref)) {
errors.push(`[${index}] Unresolved reference: ${doc.tv_author_ref}`);
}
return errors;
}
const allErrors = converted.flatMap((doc, i) => validateDoc(doc, i));
if (allErrors.length > 0) {
console.error('Validation failed:\n' + allErrors.join('\n'));
process.exit(1);
}Running this before the PHP import step means you either import clean data or import nothing.
Step 4: Convert Portable Text to HTML
Portable Text is Sanity's rich text format — a JSON array of block objects with nested children, marks, and mark definitions. MODX expects plain HTML in its content field. This conversion is typically the most labor-intensive part of the migration.
The current maintained library is @portabletext/to-html (npm). The older @sanity/block-content-to-html package is deprecated and no longer receives updates; do not use it for new migration projects.
npm install @portabletext/to-htmlimport { toHTML } from '@portabletext/to-html';
import { readFileSync } from 'fs';
const ndjson = readFileSync('./sanity-export/production.ndjson', 'utf-8');
const docs = ndjson.trim().split('\n').map(JSON.parse);
const articles = docs.filter(d => d._type === 'article' && !d._id.startsWith('drafts.'));
const converted = articles.map(article => {
const htmlBody = article.body ? toHTML(article.body, {
components: {
types: {
image: ({ value }) => {
// Sanity image ref format: image-{hex}-{WxH}-{ext}
// The hex portion does not contain hyphens, so splitting on '-' is safe
// only after stripping the 'image-' prefix first
const withoutPrefix = value.asset._ref.replace(/^image-/, '');
const lastDash = withoutPrefix.lastIndexOf('-');
const secondLastDash = withoutPrefix.lastIndexOf('-', lastDash - 1);
const id = withoutPrefix.substring(0, secondLastDash);
const dimensions = withoutPrefix.substring(secondLastDash + 1, lastDash);
const format = withoutPrefix.substring(lastDash + 1);
return `<img src="/assets/images/sanity-migrated/${id}-${dimensions}.${format}" alt="${value.alt || ''}" />`;
},
code: ({ value }) => {
return `<pre><code class="language-${value.language || ''}">${value.code}</code></pre>`;
},
callout: ({ value }) => `<div class="callout callout--${value.tone || 'info'}">${value.text}</div>`
},
marks: {
internalLink: ({ children, value }) => {
return `<a href="/${value.slug}">${children}</a>`;
},
link: ({ children, value }) => {
return `<a href="${value.href}" target="_blank" rel="noopener">${children}</a>`;
}
}
}
}) : '';
return {
_id: article._id,
pagetitle: article.title,
alias: article.slug?.current || '',
content: htmlBody,
description: article.excerpt || '',
publishedon: article.publishedAt || '',
createdon: article.publishedAt || article._createdAt || '',
tv_author_ref: article.author?._ref || '',
};
});
process.stdout.write(JSON.stringify(converted, null, 2));Edge cases in Portable Text conversion
- Custom block types — Every custom block type (callouts, embeds, tables, CTAs) requires an explicit serializer in the
typesmap. Unhandled types produce empty output with no error. Enumerate all custom types from your schema extract before writing serializers. - Nested marks — Text with overlapping annotations (e.g., bold + link simultaneously) creates multiple entries in
markDefs. The library handles standard combinations, but custom mark serializers must account for marks receiving pre-renderedchildrenHTML. - Internal reference marks — Portable Text
markDefsfor internal links store a_refto another Sanity document. These cannot be resolved to MODX resource IDs during the conversion pass because MODX IDs don't exist yet. Convert them to placeholder tokens (e.g.,data-sanity-ref="originalId") and resolve them in a post-import sweep after the ID map is complete. - Image hotspots and crops — Sanity images can carry
hotspotandcropmetadata. MODX has no equivalent. Decide whether to apply crops as query parameters during Sanity CDN download or discard them. Discarding is the simpler option for most migrations. - Asset ref format — The Sanity asset
_refformat isimage-{hexId}-{WxH}-{ext}. The hex ID portion does not contain hyphens, but a naiveref.split('-')will produce incorrect results if applied to the full string including theimage-prefix. Strip the prefix before parsing, as shown in the code above.
Step 5: Resolve references and build an ID map
Sanity uses document-level references (_ref fields) to link content. MODX has no built-in document reference system — related resource IDs are stored in Template Variables or expressed as parent-child relationships. Because referenced resources may not exist yet when you import the referencing document, the import requires two passes.
Pass one: Create all resources. Record the mapping from sanity_id → modx_resource_id. Store the original Sanity _id in a hidden TV (e.g., legacy_sanity_id) for traceability and to support post-migration link audits. Persist the ID map to a JSON file so the process can resume if interrupted.
Pass two: Iterate through the resources. For every reference field, look up the mapped MODX resource ID and update the TV value.
// First pass: create resources, build ID map
$idMap = [];
$usedAliases = [];
foreach ($transformedDocs as $doc) {
$alias = deduplicateAlias($doc['alias'], $usedAliases);
$resource = $modx->newObject('modResource');
$resource->set('pagetitle', $doc['pagetitle']);
$resource->set('alias', $alias);
$resource->set('template', $templateId);
$resource->set('parent', $parentId);
$resource->set('published', 1);
$resource->set('richtext', 0);
$resource->set('publishedon', strtotime($doc['publishedon']));
$resource->set('createdon', strtotime($doc['createdon'] ?: $doc['publishedon']));
$resource->setContent($doc['content']);
$resource->save();
$modxId = $resource->get('id');
$idMap[$doc['_id']] = $modxId;
// Store original Sanity ID for traceability
$resource->setTVValue('legacy_sanity_id', $doc['_id']);
}
// Persist ID map between passes
file_put_contents('./id-map.json', json_encode($idMap));
// Second pass: resolve references
$idMap = json_decode(file_get_contents('./id-map.json'), true);
foreach ($transformedDocs as $doc) {
if (!empty($doc['tv_author_ref'])) {
$resourceId = $idMap[$doc['_id']];
$authorResourceId = $idMap[$doc['tv_author_ref']] ?? null;
if ($authorResourceId) {
$res = $modx->getObject('modResource', $resourceId);
$res->setTVValue('author_resource_id', $authorResourceId);
$res->save();
}
}
}Set richtext to 0 on all imported resources. MODX's rich text editor (TinyMCE or CKEditor depending on version) will attempt to normalize HTML on the next edit, which mangles programmatically generated markup — stripping data attributes, restructuring tables, and collapsing custom block wrappers. Editors can re-enable the rich text editor per-resource after reviewing the imported content.
Setting createdon to preserve publication dates
Always set both publishedon and createdon from your Sanity source dates. MODX uses createdon in RSS feeds, archive queries, and some template tag outputs. Omitting it means all imported resources show the import date rather than their original publication date, which breaks chronological archives and can affect feed readers.
$resource->set('publishedon', strtotime($doc['publishedon']));
$resource->set('createdon', strtotime($doc['createdon'] ?: $doc['publishedon']));For array fields, MODX's MIGX extra expects a JSON-encoded array string. Take the Sanity array, map it to the target structure, json_encode() it in PHP, and write it directly to the MIGX TV:
$migxData = array_map(fn($block) => [
'heading' => $block['heading'] ?? '',
'body' => $block['body'] ?? '',
], $doc['flexible_blocks']);
$resource->setTVValue('flexible_blocks_migx', json_encode($migxData));Step 6: Migrate assets and rewrite URLs
Sanity stores assets in its CDN. These URLs are tied to your Sanity project and subscription; do not depend on them as permanent references in your MODX installation.
The export archive from sanity dataset export includes original files in an images/ folder. Extract them and copy to your MODX server:
tar -xzf sanity-export.tar.gz -C ./extracted/
cp -r ./extracted/images/* /path/to/modx/assets/images/sanity-migrated/After copying, verify three things for every asset:
- The file exists at the new path on the MODX server.
- All
<img src>references in the converted HTML point to the new local path, not the Sanity CDN URL. - Any image TVs store the new file path.
For large asset libraries (thousands of files), process in batches and validate checksums against the export manifest to catch corrupted transfers:
# Generate checksums during extraction for later verification
find ./extracted/images -type f | xargs md5sum > extracted-checksums.txt
find /path/to/modx/assets/images/sanity-migrated -type f | xargs md5sum > server-checksums.txt
diff extracted-checksums.txt server-checksums.txtHow to handle the MODX resource tree structure
MODX organizes Resources in a parent-child tree that defines both URL structure and navigation. Sanity has no built-in hierarchy — documents are flat with optional reference fields to express relationships.
Unlike migrating from a hierarchical system to a flat headless CMS, you must map Sanity's flat structure to MODX's tree using these patterns:
- Blog posts → Create a "Blog" container resource (
isfolder = 1), import posts as children withparentset to the container's resource ID. - Pages → Mirror slug hierarchy. If Sanity used slugs like
/about/team, create an "About" parent resource and a "Team" child resource inside it. - Authors/categories → These typically become either parent resources in their own branch, dedicated TVs on the resources that reference them, or a custom xPDO table (for very large taxonomies). The right choice depends on whether authors/categories need their own rendered pages.
$blogContainer = $modx->newObject('modResource');
$blogContainer->set('pagetitle', 'Blog');
$blogContainer->set('alias', 'blog');
$blogContainer->set('template', $containerTemplateId);
$blogContainer->set('isfolder', 1);
$blogContainer->set('published', 1);
$blogContainer->save();
$blogParentId = $blogContainer->get('id');Running the full import script
The import script runs as a standalone PHP file that bootstraps the MODX instance using MODX_API_MODE. This gives you full access to the xPDO API without going through the manager interface.
<?php
define('MODX_API_MODE', true);
require_once '/path/to/modx/index.php';
$modx->initialize('web');
$modx->getService('error', 'error.modError');
$modx->setLogLevel(modX::LOG_LEVEL_INFO);
$modx->setLogTarget('ECHO');
$data = json_decode(file_get_contents('./transformed-articles.json'), true);
$templateId = 4; // Article template ID
$parentId = 12; // Blog container resource ID
$imported = 0;
$errors = [];
$idMap = [];
$usedAliases = [];
$batchSize = 200;
foreach ($data as $index => $doc) {
$alias = deduplicateAlias($doc['alias'], $usedAliases);
$existing = $modx->getObject('modResource', ['alias' => $alias, 'parent' => $parentId]);
if ($existing) {
$errors[] = "Duplicate alias under parent {$parentId}: {$alias}";
continue;
}
$resource = $modx->newObject('modResource');
$resource->set('pagetitle', $doc['pagetitle']);
$resource->set('alias', $alias);
$resource->set('description', $doc['description']);
$resource->set('template', $templateId);
$resource->set('parent', $parentId);
$resource->set('published', 1);
$resource->set('richtext', 0);
$resource->set('publishedon', strtotime($doc['publishedon']));
$resource->set('createdon', strtotime($doc['createdon'] ?: $doc['publishedon']));
$resource->setContent($doc['content']);
if ($resource->save()) {
$idMap[$doc['_id']] = $resource->get('id');
if (!empty($doc['tv_author_name'])) {
$resource->setTVValue('author_name', $doc['tv_author_name']);
}
$imported++;
} else {
$errors[] = "Failed to save: {$doc['pagetitle']}";
}
// Prevent memory exhaustion on large imports
if ($index > 0 && $index % $batchSize === 0) {
$modx->cacheManager->refresh();
unset($resource);
}
}
// Persist ID map for reference resolution pass
file_put_contents('./id-map.json', json_encode($idMap));
// Final cache refresh
$modx->cacheManager->refresh();
echo "Imported: $imported\n";
echo "Errors: " . count($errors) . "\n";
foreach ($errors as $err) {
echo " - $err\n";
}Run from the command line:
php import-to-modx.phpMemory management: xPDO accumulates object references in memory over large loops. Call $modx->cacheManager->refresh() and unset($resource) every 200 records. On PHP 7.4+ with a 512 MB memory limit, this batch size handles imports of 10,000+ resources without hitting memory limits. Adjust based on your server's memory_limit setting.
No native transaction support: xPDO does not expose beginTransaction() at the application level. To protect against partial imports, write the $idMap to disk after every batch. If the script fails mid-run, filter out already-imported _id values using the persisted map and restart from the last checkpoint rather than re-running from the beginning.
What about URL redirects and SEO?
Every migration that changes URL patterns risks breaking inbound links and causing a catastrophic loss of organic search traffic. Sanity-powered sites have URL structures defined by the frontend framework (Next.js, Gatsby, Nuxt), not by Sanity itself. MODX generates URLs natively from the resource tree and alias configuration.
Before cutover:
- Export all existing URLs by crawling the live site with Screaming Frog or extracting from your sitemap.
- Map old URLs to new MODX aliases. If your Sanity frontend served
/blog/my-postand MODX will also serve/blog/my-post, confirm thealiasfield matches and the parent resource structure produces the same path. - Set up 301 redirects for any URL that changes. Insert redirect mappings into the MODX Redirector extra via your xPDO script, or write an
.htaccessredirect map. For more than 50 redirects, the.htaccessmap is typically more reliable and avoids database lookups on each request.
Do not skip redirect mapping. A site migration that changes 30% of its URLs without 301 redirects can lose 20–40% of organic search traffic for 3–6 months while search engines re-crawl and re-index.
Post-migration validation checklist
After import, verify the following before pointing DNS or going live:
- Document count — MODX Resource count matches expected Sanity document count (published documents only, excluding
drafts.prefixed IDs and system documents like_.assets). - Content integrity — Spot-check 15–20 resources. Compare HTML in MODX against the original rendered output. Look for missing images, stripped custom blocks, and mangled marks.
- TV values — Query a sample of resources via xPDO to confirm TV values populated correctly, including MIGX JSON fields and reference IDs.
- Asset links — Crawl the migrated site for 404s on image and file URLs. Tools: Screaming Frog,
wget --spider, or a custom curl loop over the asset path list. - URL structure — Confirm that resource aliases and parent structure produce the expected URLs using
$modx->makeUrl($resourceId). - Internal links — Check that links in content bodies point to valid MODX resource URLs, not old Sanity-frontend paths or placeholder
data-sanity-reftokens from the Portable Text pass. createdonandpublishedondates — Verify a sample of resources show original publication dates, not the import timestamp.- Cache — Clear the MODX site cache via Manager > Manage > Clear Cache, then verify pages render correctly from a cold cache.
- ID map coverage — Confirm that every Sanity
_idin the source data has a corresponding entry inid-map.json. Missing entries indicate records that failed silently.
When this migration gets hard
Some scenarios add significant complexity:
- Heavy use of Portable Text custom blocks — Every custom block type (tables, callouts, CTAs, embedded media) requires its own serializer. Sites with 10 or more custom block types can spend more engineering time on serializers than on the import script itself. Enumerate all custom types from
schema.jsonbefore estimating effort. - Deeply nested references — A blog post that references an author who references an organization who references an address requires resolving the full reference graph. Decide at the start whether nested references become flattened TVs, additional Resources, or are denormalized into the parent document's MIGX field.
- Multi-language content — Sanity handles i18n through the
@sanity/document-internationalizationplugin (separate documents per locale) or field-level translation objects. MODX handles multi-language via Contexts, with separate resource trees per language. The mapping is non-trivial: you must create one MODX Context per language, configure context settings, and import documents into the correct context tree with matchingcultureKeysettings. - Large asset libraries — Libraries of 5,000+ images require batch extraction, checksum validation, and staged transfers. Plan for disk space equal to at least 2× the raw asset size during extraction.
- Phased migration / parallel operation — Some teams run MODX and Sanity simultaneously during a transition period, with Sanity as the source of truth. In this pattern, a scheduled MODX Snippet or CLI cron job polls the Sanity Content Lake API (using a
_updatedAt > lastSyncTimestampGROQ filter) and re-imports modified documents. This keeps MODX current without a hard cutover but requires the full migration pipeline to be idempotent — detecting existing resources bylegacy_sanity_idTV and updating rather than creating. - MIGX field structure — MIGX TVs expect JSON arrays that exactly match the column configuration defined in the MIGX TV settings. A mismatch between the JSON keys you write and the column names configured in the MODX Manager produces blank output with no error. Verify MIGX column names against the TV configuration before running the import.
Making the right call
A Sanity-to-MODX migration is a custom engineering project, not a configuration task. Every Sanity schema is unique, and MODX's flexibility means there are multiple valid ways to model the same content. The work breaks into four concrete efforts: exporting and parsing NDJSON, converting Portable Text with all custom blocks to clean HTML, scripting the two-pass xPDO import with TV mapping and alias deduplication, and migrating assets with URL rewriting and checksum validation.
For small sites (under 100 documents, two or fewer custom block types), a single developer can complete the migration in 2–4 days. For larger sites with complex schemas — 10+ document types, custom Portable Text blocks, deep reference graphs, or multi-language content — budget 2–4 weeks of focused engineering time.
The most common failure modes, in order of frequency: unhandled Portable Text custom block types producing silent empty output, alias collisions silently dropping records, missing createdon values breaking archives and feeds, and asset URL rewrites that miss secondary locations (PDF links, background image styles, Open Graph tags).
Frequently Asked Questions
- Can I migrate from Sanity to MODX without coding?
- No. There is no plugin or built-in importer. Sanity stores content as JSON documents with Portable Text rich text, while MODX uses MySQL Resources and HTML. You need a Node.js script to convert Portable Text to HTML and a PHP script using xPDO to create MODX Resources and set Template Variable values.
- How do I convert Sanity Portable Text to HTML for MODX?
- Use the @portabletext/to-html npm package. Write a Node.js script that reads exported NDJSON, filters by document type, and converts each Portable Text field to HTML. You must write custom serializers for any non-standard block types like code blocks, images, or callouts — unhandled types silently produce empty output.
- What is the best way to import data into MODX?
- Always use a standalone PHP script that bootstraps the MODX core and utilizes the xPDO ORM layer. Never write raw SQL inserts — they bypass caching, fail to generate URI paths, and can corrupt the hierarchical index.
- How long does a Sanity to MODX migration take?
- For small sites under 100 documents with simple schemas, expect a few days of developer time. Larger sites with custom Portable Text blocks, complex reference graphs, and thousands of assets can take one to three weeks of focused engineering.
- What happens to Sanity image assets during migration to MODX?
- The Sanity export archive includes an images directory. Copy these files to your MODX assets directory, then rewrite all image URLs in the converted HTML and image TVs to point to the new local paths. Do not rely on Sanity CDN URLs long-term — they are tied to your Sanity project and plan.