Content Mapping Templates for Legacy-to-Headless Transition

Within Legacy System Decoupling Strategies, the mapping template is the artifact everyone reviews. A legacy-to-headless migration needs a deterministic mapping contract that turns implicit, template-bound legacy data into explicit, schema-validated JSON. The contract preserves referential integrity, keeps rich-text ASTs intact, and synchronizes draft/publish state across decoupled environments. Without it, ingestion scripts emit unpredictable documents that break rendering pipelines and preview environments.

Why ingestion breaks without a contract

Legacy CMS platforms store content as serialized HTML in relational tables or document stores, relying on server-side rendering to resolve shortcodes, inline styles, and implicit media dependencies. Decoupling without a strict field-to-component contract fractures that structure: WYSIWYG editors embed layout-specific markup, tracking pixels, and relative asset paths with no structured metadata. Headless platforms, by contrast, enforce normalized fields, explicit cross-content references, and finite state machines separating draft, published, and archived records.

Bypass the mapping layer and three failure modes appear:

  1. Orphaned assets and broken references. Relative URLs and shortcode media embeds fail against headless asset CDNs, producing broken image/video nodes.
  2. Malformed rich-text ASTs. Serialized HTML injected into rich-text fields without AST normalization violates the target node schema and crashes preview renderers.
  3. Draft state desync. Legacy publish timestamps mapped straight to live endpoints bypass validation gates and trigger premature webhook rebuilds.

A structured mapping template enforces schema normalization, deterministic transformation, and state-aware ingestion. Within Legacy System Decoupling Strategies, this contract is the single source of truth for translation — every legacy record maps to a predictable, API-ready document.

The three-phase pipeline

Each legacy record flows through inventory, transformation, and validation before it becomes a publishable document:

A record's path through the mapping pipelineEach legacy record is mapped by the inventory template, transformed with HTML-to-AST and URL normalization, pushed to the draft endpoint with a migration flag, and validated in staging preview; failures are logged to the migration ledger and rolled back.Legacy recordPhase 1field mappingPhase 2transformDraft endpoint_migration_draftPhase 3schema + previewHold forpublicationLedgerrollbackvalidinvalid
Nothing is published by the pipeline itself; publication stays a deliberate human step after validation.

1. Schema inventory and field mapping

Extract the legacy schema and map each table/column to a target content type. The template explicitly defines:

  • Source data paths (SQL columns, JSON keys, serialized-HTML selectors)
  • Target field types (string, number, reference, rich-text, media)
  • Transformation functions (HTML-to-AST, URL normalization, shortcode extraction)
  • Validation constraints (required fields, regex patterns, enum limits)
Legacy source shapes and their target typesCommon legacy data shapes found in WordPress and Drupal databases, the headless field type they map to, and the transform applied.Legacy sourceTarget typeTransformpost_content HTMLrich text / blockshtml_to_ast, shortcode extractionmeta_value serialized arrayobject or referencesunserialize + reference resolutionattachment idasset referenceresolve_asset_url, uploadterm relationshipsreference listtaxonomy lookup by legacy idpost_statusworkflow stateexplicit status map
Every row of this table becomes one field mapping entry in the template.

2. Transformation pipeline execution

Build an ETL runner that consumes the template, fetches content in paginated batches, applies field-level transformations, and pushes payloads to the headless API. Tag migrated entries with a _migration_draft flag and defer publication until validation passes. Use cursor-based pagination to avoid memory exhaustion and exponential backoff for rate limits.

3. Validation and state sync

Run JSON Schema validation against the model before submission. Confirm draft entries resolve in staging by querying the preview endpoint with explicit draft parameters. Log transformation outcomes, validation failures, and API responses to a migration ledger for auditability and rollback. Strict draft isolation here — per Preview & Draft Workflow Patterns — prevents premature publication and lets frontend teams test content safely before go-live.

Mapping template and ETL runner

This JSON template is the declarative source of truth for field transformations, defining source paths, target types, AST conversion, and draft handling.

JSON
{
  "mappingTemplate": {
    "version": "1.0.0",
    "sourceSystem": "legacyRelationalCMS",
    "targetSystem": "headlessCMS",
    "contentTypes": {
      "article": {
        "sourceTable": "wp_posts",
        "targetModel": "blog_post",
        "fieldMappings": [
          {
            "sourcePath": "post_title",
            "targetField": "title",
            "type": "string",
            "transform": null,
            "validation": { "required": true, "maxLength": 255 }
          },
          {
            "sourcePath": "post_content",
            "targetField": "body",
            "type": "rich_text",
            "transform": "html_to_ast",
            "validation": { "required": true, "allowedNodes": ["paragraph", "heading", "image", "link"] }
          },
          {
            "sourcePath": "post_excerpt",
            "targetField": "summary",
            "type": "string",
            "transform": "strip_html",
            "validation": { "maxLength": 160 }
          },
          {
            "sourcePath": "featured_image_id",
            "targetField": "hero_image",
            "type": "reference",
            "transform": "resolve_asset_url",
            "validation": { "required": false }
          }
        ],
        "stateConfig": {
          "draftFlag": "_migration_draft",
          "publishStrategy": "deferred",
          "previewEndpoint": "/api/preview?draft=true"
        }
      }
    }
  }
}

The runner consumes the template, applies transformations, validates payloads, and ingests with draft isolation.

TypeScript
import { readFileSync } from 'fs';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

// Load mapping template and target JSON Schema
const mappingTemplate = JSON.parse(readFileSync('./mapping-template.json', 'utf-8'));
const targetSchema = JSON.parse(readFileSync('./headless-cms-schema.json', 'utf-8'));

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(targetSchema);

interface LegacyRecord {
  id: number;
  post_title: string;
  post_content: string;
  post_excerpt: string;
  featured_image_id: string | null;
}

// Mock transformation functions (replace with production implementations)
const transforms = {
  html_to_ast: (html: string) => {
    // Use standards-compliant W3C HTML Parsing rules to convert to AST
    // Implementation typically leverages rehype/remark or custom DOMParser
    return { type: 'root', children: [{ type: 'paragraph', value: html }] };
  },
  strip_html: (html: string) => html.replace(/<[^>]*>/g, ''),
  resolve_asset_url: (id: string) => `https://cdn.example.com/assets/${id}.webp`
};

async function executeMigrationBatch(records: LegacyRecord[]) {
  const results = { success: 0, failed: 0, errors: [] as string[] };

  for (const record of records) {
    const payload: Record<string, unknown> = {
      _migration_draft: true, // Enforce draft isolation
      legacy_id: record.id
    };

    try {
      // Apply field mappings
      for (const mapping of mappingTemplate.mappingTemplate.contentTypes.article.fieldMappings) {
        const rawValue = record[mapping.sourcePath as keyof LegacyRecord];
        if (rawValue === undefined || rawValue === null) {
          if (mapping.validation.required) throw new Error(`Missing required field: ${mapping.sourcePath}`);
          continue;
        }

        const transformed = mapping.transform 
          ? transforms[mapping.transform as keyof typeof transforms](rawValue as string) 
          : rawValue;

        payload[mapping.targetField] = transformed;
      }

      // Validate against headless CMS schema
      const isValid = validate(payload);
      if (!isValid) {
        throw new Error(`Schema validation failed: ${JSON.stringify(validate.errors)}`);
      }

      // Push to headless CMS API (draft endpoint)
      await fetch('https://api.headless-cms.com/v1/content/blog_post', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.CMS_TOKEN}` },
        body: JSON.stringify(payload)
      });

      results.success++;
    } catch (error) {
      results.failed++;
      results.errors.push(`Record ${record.id}: ${(error as Error).message}`);
    }
  }

  return results;
}

Validation checklist

  • AST node compliance. The html_to_ast transformer must strip legacy inline styles (style="...") and convert deprecated tags (<font>, <center>) to semantic equivalents before serialization.
  • Reference resolution. Resolve asset references synchronously or queue them for background processing; unresolved references throw 404s in preview.
  • Draft state verification. Query the preview endpoint with ?draft=true right after ingestion and confirm _migration_draft: true blocks public API exposure and suppresses webhook rebuilds until manual publication.
  • Idempotency. Make legacy_id a unique constraint in the target CMS so pipeline retries don’t create duplicates.
Ingestion outcome over three pipeline runsShare of records ingested successfully, quarantined for validation errors and failed at the API over three iterative runs of the mapping pipeline as transforms were fixed.Run 1: valid71 %Run 2: valid93 %Run 3: valid99.2 %Remaining 0.8 percent went to editors as a review list.
Each run fixed the largest class of failures in the transforms rather than in individual records.

Enforcing this contract removes guesswork from migration, guarantees schema compliance at ingestion, and keeps full control over the draft-to-publish workflow.

Configuration Reference

Template key Purpose
version Versioned with the pipeline; changes are reviewed like code.
sourcePath Column, JSON key or selector in the legacy data.
targetField / type Field in the target model and its type.
transform Named function applied to the source value.
validation Constraints checked before submission.
stateConfig.publishStrategy deferred keeps migrated items as drafts until reviewed.

Keeping transforms as named functions referenced by the template, rather than inline code, has a practical benefit: content strategists can review and change the mapping, such as which field feeds which target, without touching transform code, while engineers own the functions and their tests.

Gotchas & Edge Cases

  • Placeholder transforms. The html_to_ast stub above wraps raw HTML in a paragraph node, which is only a placeholder. Use a real HTML parser such as rehype, map elements to the target rich text schema, and fail on elements you do not support.
  • Missing upserts. The runner POSTs every record, which duplicates entries on re-runs unless the target enforces legacy_id uniqueness. Query by legacy_id first and update if found.
  • Silent API failures. The fetch call does not check response.ok, so API errors count as successes. Check the status and record the response body in the ledger.
  • Locale fields. Multilingual legacy sites often store translations as separate posts linked by a plugin. Map translation groups to one target entry with localized fields, or the new CMS will contain unconnected duplicates.

Worked Example

A media company migrating 30,000 WordPress articles to Sanity started with a hand-written migration script that grew to 2,000 lines of special cases. Nobody outside the engineering team could say which legacy field ended up where. Rewriting it as a mapping template with twelve named transforms made the mapping reviewable in an afternoon by the managing editor, who spotted that pull quotes stored in a custom field were being dropped. The pipeline then ran three times, each run fixing the largest remaining class of failures, and the final run quarantined fewer than 250 articles, which editors fixed by hand over a week.

Frequently Asked Questions

Who should own the mapping template?

A content strategist or lead editor owns the mapping decisions, and an engineer owns the transform functions. Reviewing template changes together catches most modeling mistakes before a single record moves.

Can the same template drive incremental syncs?

Yes. During parallel running, the same template and runner can process only records changed since the last run, keyed by legacy id, which keeps both systems in step until the legacy CMS is frozen.

How do we handle content types that do not map cleanly?

Quarantine them rather than forcing a mapping. A small number of oddities, such as a custom plugin’s layout builder, are often cheaper to rebuild manually in the new CMS than to transform automatically.

How should images be migrated alongside text?

Upload assets through the target CMS’s asset API in a separate, earlier pass, record the mapping from legacy attachment id to new asset id, and let the content transform look up that mapping. Separating the passes keeps retries cheap, because a failed article never re-uploads its images.