Content Entry · Sitecore
Sitecore XM Cloud Migration Case Study: Nearly 100 Pages
How we moved a regional health plan's site to Sitecore XM Cloud: nearly 100 accessible pages and a 180-item notice library, rebuilt with AI-assisted checks.

Overview
We rebuilt the public website of a regional health plan on a new content platform (Sitecore XM Cloud). I led the content build, turning nearly 100 older pages into clean, accessible pages made from the new design system's building blocks.
Who it serves: members looking for benefits, documents and help, and the doctors, clinics and hospitals that work with the plan.
My role: content migration lead on the agency team. I worked alongside a tech lead, QA reviewers and front-end developers.
How: I worked with an AI agent connected to the content system and approved every change before it was saved.
Timeframe: the build and QA described here ran over about two weeks.
The problem
The old site held years of content in mixed formats. Copying it across as it was would have carried the old problems straight into the new design.
Messy formatting: pages used hand-applied styling, underlines, blank spacer lines and tables for layout. These look wrong in the new design and confuse screen readers.
Broken and scattered links: many links pointed to old addresses or to files stored outside the new media library.
Volume: some pages held long lists, such as a library of 180 provider notices. That was too many to enter by hand without mistakes.
No room for error: health information had to match the approved wording exactly, typos included, so every page needed checking against the original.
The approach
We treated this as content design, not a straight copy. The approved words stayed exactly as they were, while headings, links, images and layout were rebuilt for the new component library and for accessibility. The target was Sitecore XM Cloud with a headless Next.js front end hosted on Vercel and styled with Tailwind CSS utility classes. Content was edited through Sitecore's CKEditor-based rich text fields and Media Library. I ran the build through an AI agent (Claude) connected to Sitecore through the Sitecore Marketer MCP server. Checks against the live site ran in Chrome, and bulk data work ran in Python.
1. A written rulebook before building
With dozens of pages and several reviewers, decisions drift unless they're written down. Every review comment became a numbered, dated rule in a shared conventions document.
Copy is fixed, markup is not: the words follow the source exactly. Suspected typos, wrong postcodes or mismatched email addresses are flagged to the client, not quietly fixed.
Semantic headings with a separate visual scale: the design renders headings two steps smaller, so tags are chosen for hierarchy (
<h2 class="text-h4">,<h3 class="text-h5">) and levels are never skipped. Spacing before a heading uses<br /> inside the previous element instead of empty<p>tags. No<u>, inline styles or centring.Links and phone numbers: internal links open in the same tab. External links and PDFs use
target="_blank" rel="noopener noreferrer". Every phone number becomes atel:link, including short ones liketel:911andtel:711, while fax numbers stay as plain text.Images: alt text follows WCAG 1.1.1 (technique H67). Decorative stock photos get
alt="", and charts or images containing text get a description. Images are wrapped in a<div>so the editor's serializer doesn't wrap them in<p>.
2. Mapping free-form pages to a fixed component set
The old pages were free-form, but the new site builds pages from a set number of components placed into named layout slots (placeholders). Each content pattern needed one agreed home.
Layout slots: body components go into the 70% column of a two-column container, with the sub-navigation in the 30% column. When a page template was switched, components stayed attached to the old container's placeholder and stopped rendering. We moved them with
targetPlaceholderPathand chainedinsertAfterComponentIdto keep their order.One pattern, one component: headed link lists became a rich text heading followed by Quick Links cards. Text with an image became a 50/50 media-and-text block, with the image side alternating down the page. Long document libraries became a resource hub with one detail page per document, listed through Sitecore Search. We compared options per pattern: the 50/50 block needs a real photo, and the hub needs one item per document.
Shared or page-specific: a shared content item was reused only when its copy matched the page's source word for word, and its references were checked before any edit. Otherwise we made a page-specific copy, so fixing one page couldn't silently change another.
Navigation highlighting: every page had to highlight exactly once in the side navigation (
aria-current). New entries were appended to shared multilist fields and read back to confirm nothing was lost. The rendered nav was then diffed before and after with Python'sdifflib.
3. AI-assisted building with human approval
The agent made the work fast, so the process had to make it safe. The guardrails mattered more than the speed.
Approve every write: the agent proposed each create, update, move or delete, and I approved it before it ran. The agent never published. I reviewed and handled publishing, including shared navigation.
A handoff log between sessions: a progress log, a component field reference and working notes meant every session started from the same rules. Lessons were recorded as they happened.
No hand-typed IDs: two item IDs were mistyped early on, and both were caught when the saved fields were read back. After that, every ID was copied from tool output or generated by script, and every link or multilist value was read back after saving.
One call at a time: adding components in parallel scrambled their order on the page. Calls ran one at a time, each anchored to the previous one, and the resulting instance IDs were read from the page layout afterwards.
4. Treating large content as data
Long tables, like the 180-notice library and toolkits of 36 and 40 resources, were too big to enter by hand reliably. We scraped them, verified them and generated them from data.
Scraping with an integrity check: the live tables were paginated React components. Browser script output was truncated at about 1,700 characters and blocked when it contained query strings. So rows were serialised in the browser, read back in 950-character slices, rebuilt locally, and accepted only when the length and a rolling checksum (
(h*31+c)>>>0) matched.Generated field values: Python scripts turned each scrape into field values or table markup using a lookup of Media Library IDs. Any file missing from the library fell back to its live URL and was printed for review.
A sorting gotcha: the resource grid's "Most Recent" sort follows the order items were created in, not their document date. We deleted the pilot items and created all 180 notices one at a time in reverse source order. We checked the first page of results against a screenshot of the source before running the rest.
Every file checked: each notice's linked PDF was confirmed through an asset lookup, and all 180 matched.
5. Working within the platform's CSS
Rich text inherits global site styles, and content has to survive being re-saved in the editor. Several fixes had to work with the existing CSS instead of asking for code changes.
Narrow table columns: the site CSS sets
.richtext td {min-width:200px}, which stretched a 40px icon column to 200px. Rather than override it, we used CKEditor's own resized-table markup:class="ck-table-resized"plus a<colgroup>of percentage widths. The site CSS already exempts it, and the editor keeps it on save.Full-width tables:
width: fit-content !importanton the table's<figure>beat width attributes and lower-specificity utility classes. The fix wasstyle="width:100% !important"on the figure itself, flagged for re-checking after any editor save.Only classes that exist: Tailwind ships only the classes the site already uses. Before replacing layout tables with grid
<div>s, we walkeddocument.styleSheets, including rules nested in@layerblocks. That confirmedmd:grid-cols-2was available and plaingrid-cols-2was not.List counters: a nested
<ol><li><ul>rendered the numbers 1, 5, 9 because the counter included the inner items. We kept the source's own numbered paragraphs followed by a<ul>.
6. Verification against the source and the render
Each page was checked twice: against the live original for wording and links, and against the rendered preview for structure.
Section-by-section source check: a browser script hashed each section's text and links on the live page, and Python built the same hashes from the CMS. On one page, 8 of 12 sections matched and 4 had changed on the live site since migration. So pages were re-checked just before handoff.
Structural checks from the page data: several components render on the client and show "Loading component..." in the server HTML. So checks parsed
__NEXT_DATA__instead, confirming oneh1, onearia-current, one header and one footer, and no relative links to old-site paths.Visual checks: full-height preview screenshots were decoded with Pillow, cropped by section and reviewed. That's how issues like an icon column gap or a floated icon indenting the list below it were caught.
Unmatched links handled consistently: internal links with no match in the new site pointed to the live source URL, and each was listed for the client.
7. Clean-up, tool blind spots and workarounds
Swapping templates and components leaves orphaned content items behind. The tools also had limits we had to design around.
Safe deletion: about 248 unused items across about 45 pages were deleted. Each was re-checked for zero references just before deletion and recorded in a ledger with its ID.
A blind spot in reference checks: the reference lookup missed links from items stored inside a page's own data folder. One location's cards were wrongly skipped as unused until I noticed they were live. After that, items were confirmed against the page layout before being treated as unused.
Tool and platform regressions: late in the project the add-component call started failing with a 400 error. The agent prepared the content items and I placed the components in the visual editor. Phone links in directory cards rendered as
http://tel:until the developers fixed it, so we held off making those cards clickable until then.Icon transparency: JPG icons showed white squares on the off-white page. A Pillow script flood-filled from the image border over near-white pixels, applied soft alpha and removed the white from the edge colours. That kept the white symbols inside each icon and gave clean PNGs.
The outcome
By the end of this phase, nearly 100 pages were built or brought up to one consistent standard, ready for the client's review and publishing.
Nearly 100 pages in about two weeks: every page followed the same accessibility and formatting rules, with each change approved before saving.
180 out of 180: a notice library that was 8 pages of static tables became 180 searchable entries. Every file link was verified and none were typed by hand.
Cleaner navigation and less clutter: 78 navigation links that opened in the wrong tab were corrected, 72 misleading icons were replaced, and about 248 leftover content items were safely removed.
Limitations and next steps: we didn't measure speed or traffic, and the work was still waiting for the client to publish when these notes were written. Next is publishing, then measuring. Some uploaded images (up to 18 MB) need optimising, and a few changes such as a hard-coded box title still need developer work.