Content Audit Workflows Before Headless Migration
Part of Legacy System Decoupling Strategies, this audit comes first, because migrating off a monolithic CMS is a structural transformation, not a data export — and it fails when legacy content reaches the frontend without deterministic validation. The recurring symptoms are broken component contracts, stale preview environments, and cascading build failures. A production-safe audit pipeline maps implicit dependencies, normalizes draft states, and sanitizes asset references before decoupling begins.
What actually breaks
Failed migrations rarely stem from infrastructure. They come from three data-integrity failures that surface only when payloads hit the delivery layer:
- Implicit relational dependencies. Monolithic systems store content as rendered HTML blobs, inline styles, and implicit joins. Extracted via REST or GraphQL, these yield unstructured payloads that violate component interfaces; without explicit foreign keys, relational queries return
nullor malformed arrays and break hydration and ISR fallbacks. - State parity degradation. Draft and published states live in legacy flags (
post_status,is_published,visibility) rather than API-native versioning. Token-based preview systems expectingdraft/publishedenums render stale content, fail auth handshakes, or skip cache invalidation. - Orphaned asset references. Media URLs embed absolute paths, legacy CDN prefixes, or cache-buster query strings (
?v=1.2.3). During static generation these trigger 404 cascades, block builds, or bypass webhook-triggered rebuilds when the origin returns301redirects the bundler can’t follow.
The audit pipeline
The four gates run in order, each blocking ingestion until legacy content clears it:
Run the gates sequentially before any ingestion:
- Schema contract definition. Map legacy content types to headless models. Flag deprecated fields, inline HTML needing component extraction, and implicit relationships requiring explicit foreign-key resolution.
- Draft state normalization. Extract revision history, map legacy status flags to headless enums, and confirm preview tokens resolve to the correct revision hash — keeping editorial workflows and frontend routing in parity.
- Asset URL hygiene. Crawl every media reference, strip legacy query parameters, normalize paths to target CDN prefixes, and verify
200/304responses before ingestion. - Reference graph validation. Resolve cross-content relationships (related posts, taxonomy, navigation) and flag dangling references before they cause routing errors.
Schema validation and draft state mapping
This Node.js routine fetches legacy content, normalizes state flags, validates payloads against the target schema with Zod, and emits a structured audit report. It assumes a paginated REST or GraphQL endpoint and handles async concurrency safely.
import { z } from 'zod';
import { fetch } from 'undici';
import { fileURLToPath } from 'node:url';
// Target headless schema contract
const ArticleSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1, 'Title cannot be empty'),
slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
status: z.enum(['draft', 'published', 'archived']),
body_html: z.string().min(1, 'Body HTML is required'),
featured_image: z.string().url().nullable(),
related_ids: z.array(z.string().uuid()).default([])
});
// Legacy status mapping to headless enums
const STATUS_MAP = {
publish: 'published',
draft: 'draft',
trash: 'archived',
private: 'archived',
pending: 'draft'
};
export async function auditLegacyContent(endpoint, token, batchSize = 50) {
const response = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Legacy API returned HTTP ${response.status}: ${response.statusText}`);
}
const legacyData = await response.json();
const auditReport = { valid: [], invalid: [], warnings: [] };
for (const item of legacyData) {
const normalizedStatus = STATUS_MAP[item.status] ?? 'archived';
// Normalize payload to match headless contract
const payload = {
id: item.guid || item.id || crypto.randomUUID(),
title: item.title?.rendered || item.title || '',
slug: item.slug || '',
status: normalizedStatus,
body_html: item.content?.rendered || item.body_html || '',
featured_image: item._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
related_ids: item._embedded?.['wp:term']?.[0]?.map(t => String(t.id)) || []
};
const result = ArticleSchema.safeParse(payload);
if (result.success) {
auditReport.valid.push(result.data);
} else {
auditReport.invalid.push({
id: payload.id,
status: payload.status,
errors: result.error.flatten().fieldErrors
});
}
// Warn on potential asset or state mismatches
if (payload.status === 'draft' && !payload.featured_image) {
auditReport.warnings.push(`Draft ${payload.id} missing featured image (may break preview fallbacks)`);
}
}
return auditReport;
}
// CLI execution example
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const LEGACY_ENDPOINT = process.env.LEGACY_API_URL;
const AUTH_TOKEN = process.env.LEGACY_API_TOKEN;
if (!LEGACY_ENDPOINT || !AUTH_TOKEN) {
console.error('Missing LEGACY_API_URL or LEGACY_API_TOKEN environment variables');
process.exit(1);
}
auditLegacyContent(LEGACY_ENDPOINT, AUTH_TOKEN)
.then(report => {
console.log(`✅ Valid: ${report.valid.length}`);
console.log(`❌ Invalid: ${report.invalid.length}`);
console.log(`⚠️ Warnings: ${report.warnings.length}`);
console.log(JSON.stringify(report.invalid, null, 2));
})
.catch(err => {
console.error('Audit failed:', err.message);
process.exit(1);
});
}
Execution notes
- Run in CI or a staging container, and pipe the JSON output to a migration orchestrator that skips invalid payloads.
- Use
undicifor nativefetchand connection pooling; for high-volume audits, cap concurrency withp-limitto avoid legacy API throttling. - Check HTTP status explicitly. Legacy CDNs often return
301/302for moved assets, so resolve final URLs or flag them for manual review.
Wiring the audit into CI/CD
A pre-deploy job should run the validator against a staging clone of the legacy database. If the invalid array exceeds a threshold (e.g. > 2%), fail the pipeline and surface the Zod report to editors.
When normalizing draft states, align preview infrastructure with Preview & Draft Workflow Patterns so token-based auth resolves to the exact revision hash the audit validated — eliminating the common mismatch where preview routes render published payloads instead of draft revisions.
Map audit outputs to your Legacy System Decoupling Strategies before the content sync, so relational graphs, asset pipelines, and state machines stay synchronized across both environments.
Finally, validate media references against HTTP status codes during the asset-hygiene phase: static generators fail on 4xx/5xx unless fallback routing or placeholder assets are configured. Strict schema contracts and deterministic state mapping upfront eliminate most post-migration debugging and yield predictable, cache-friendly delivery.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Invalid threshold | 2 % of items | Above this, fix transforms before migrating rather than quarantining. |
| Concurrency | 5 to 10 requests | Legacy APIs and databases are often fragile under load. |
| Status map | explicit table, unknown to archived |
Nothing unexpected becomes public by accident. |
| Asset check | HEAD request, follow redirects, record final URL | Finds moved and missing media before builds do. |
| Report format | JSON plus a CSV for editors | Engineers fix transforms, editors fix content. |
The STATUS_MAP above sends unknown and private statuses to archived, which is the safe default: anything the audit does not understand stays invisible until someone decides otherwise. Review the list of items that fell into that default, because it usually reveals plugin-specific statuses, such as custom workflow states, that deserve their own mapping.
Gotchas & Edge Cases
- Rendered versus raw content. The WordPress REST API returns
content.rendered, with shortcodes already expanded and filters applied. That hides shortcode usage from the audit. Audit the rawpost_contentfrom the database as well, and count shortcodes by type. - UUID assumptions. The schema requires UUID ids, but WordPress ids are integers. Keep the legacy id as its own field and generate target ids deterministically from it, so re-runs produce the same ids.
- Pagination. The audit function reads a single response. Legacy APIs paginate, often at 100 items; loop through pages or query the database directly, or the audit silently covers only the first page.
- Editorial content in widgets and options. Footers, banners and menus often live in theme options or widgets, outside posts. Include them in the inventory, or they will be missing from the new site.
- Personal data. Comments, form entries and user profiles carry personal data with retention rules. Decide explicitly whether they migrate, and audit them separately.
Worked Example
A university’s WordPress site had 12,000 items, and the migration team expected schema problems to dominate. The audit showed otherwise: fewer than 3 percent failed the schema contract, but almost a fifth referenced media on a retired subdomain, and nearly 900 linked to deleted pages. Fixing asset URLs with a single transform and producing a list of dangling links for editors took two weeks, far less than the post-migration cleanup would have taken, and the first extraction run into the new CMS completed with under 1 percent quarantined items.
Rollout Checklist
- Inventory every content source, including widgets, options, menus and custom tables.
- Define the schema contract and status map for each target content type.
- Run the four gates against a staging copy of the legacy database, with pagination and throttling.
- Send transform problems to engineers and content problems to editors, in separate reports.
- Re-run the audit after each fix until the invalid share is below the threshold.
Frequently Asked Questions
How long does a content audit take?
The automated gates run in minutes to hours, depending on volume. The real time goes into deciding what to do with the findings, which usually takes one to three weeks with editors involved.
Should we clean content in the legacy CMS or in the transform?
Fix systematic problems, such as URL prefixes and status mappings, in the transform, so they stay fixed on every re-run. Fix one-off editorial problems, such as a broken link in one article, in the legacy CMS before extraction, or in the new CMS after it.
What should happen to content nobody has visited in years?
Decide with the content owners, using analytics. Many migrations archive rather than migrate long-unvisited content, with redirects to a relevant section, which reduces migration effort and improves the new site’s quality.