maravillajanm

Frontend · Sitecore

Next.js Migration Case Study: 510 Pages from SharePoint

How a 553-page SharePoint intranet became a static Next.js site for a regional health plan, plus the operations that keep 500+ pages accurate.

Next.JS Migration

Overview

A regional non-profit health plan ran its public website on an ageing SharePoint platform. The site was large, document-heavy and read by members, clinicians and partner organisations. We rebuilt it as a static site, and I then took on the ongoing work of keeping its content accurate.

  • The scale: 553 published addresses, 510 rebuilt pages, and 411 downloadable documents moved to a new content system.

  • The method: a repeatable pipeline crawled the old site, worked out its page patterns, and generated the replacement code, rather than rebuilding every page by hand.

  • My part: I joined once the new site was standing up and have handled its day-to-day content operations since — page edits, document publishing and link accuracy.

  • Why it mattered: much of the content is regulatory or time-sensitive, so a broken link is a member who cannot find a form they need.

The problem

The old platform made routine content changes slow and risky, and nobody could be confident the site was internally consistent. The real difficulty was not any single page but the volume and the number of ways a change could quietly go wrong.

  • Too big to hand-migrate: rebuilding 553 pages manually would have taken months and introduced its own crop of transcription errors.

  • Documents everywhere: hundreds of PDFs and Office files were linked across the site, and site search could not see inside any of them.

  • Silent breakage: publishing a document could report success while the public link stayed broken, so errors surfaced only when a member hit them.

  • Two environments to keep aligned: content had to land identically in a staging and a production system, and it was easy to update one and forget the other.

The approach

The migration runs as a five-stage pipeline in TypeScript 6.0 on Node.js 22+, using Playwright 1.58 for crawling and Cheerio 1.0 for HTML cleaning, and emitting a Next.js 15.5 site on React 19.2 with Tailwind CSS 3.4, exported as fully static HTML and hosted on Vercel. Documents live in a headless CMS (Sitecore XM Cloud) and are served from its CDN. The content-operations layer that I work in sits on top of that output, and much of the engineering effort went into verification rather than generation — the platform's failure modes are quiet, so the tooling has to prove each change actually landed.

1. Crawling and content extraction

The old platform wrapped real content in a thick layer of its own chrome, so the first job was separating content from furniture reliably enough to generate code from it.

  • Render before reading: each page is loaded in headless Chromium at a 1440px viewport and captured only after networkidle, so client-rendered content is present in the HTML that gets parsed.

  • Strip the platform chrome: Cheerio removes the known wrapper elements, inline scripts, event handlers and empty containers, leaving a cleaned document alongside the raw one for comparison.

  • Extract structure, not just text: every page produces a screenshot, raw HTML, cleaned HTML and a structured record of headings, body text, images, links and downloadable files.

  • Resume by default: pages that already have an extracted record are skipped, so an interrupted crawl of several hundred pages can be restarted without repeating work.

2. Classification and code generation

Generating 510 pages one at a time would produce 510 slightly different designs, so the pipeline first establishes a shared vocabulary of templates and components and only then writes pages.

  • Cluster before generating: URLs are grouped by path pattern and pages compared visually to identify a small set of recurring templates, captured in a blueprint that all later stages read from.

  • Strict generation order: scaffold, then shared components, then templates, then pages — so individual pages are assembled from parts that already exist rather than inventing their own markup.

  • Parallelise only the safe step: page generation is partitioned by template type across several concurrent workers; the steps that define shared code stay single-threaded to avoid divergent output.

  • Static export as the target: the site builds with output: "export" and images.unoptimized, which keeps hosting trivial but rules out any server-side rendering — a constraint every later content decision has to respect.

3. The document pipeline and its CDN boundary

Documents are the part of this site that members actually come for, and they live outside the codebase — uploaded through the CMS Authoring GraphQL API and served from its CDN. That split is where most real incidents happen.

  • Upload via presigned POST: files go through uploadMedia to obtain a presigned URL, then a multipart upload, then an explicit publish to push the item to the delivery CDN.

  • Resolve URLs at build time: pages reference documents by a root-relative media path through an assetUrl() helper that prefixes the CDN host from an environment variable, so the same source builds against either environment.

  • A hard delivery ceiling, failing silently: media beyond roughly 50 MB uploads and publishes with every success signal, records the correct byte size in the CMS, and then returns 404 BlobNotFound from the CDN indefinitely. A 71.4 MB meeting pack hit this. I compressed it to 47.3 MB with Ghostscript — downsampling embedded images to 150 DPI while leaving vector text untouched — and confirmed all 1,172 pages and the extracted text layer survived before republishing.

  • Overwrites can be skipped silently: the uploader compares the remote Size field against the local file and treats a match as "already current", so a same-size replacement reports success while changing nothing. Any genuine replacement is therefore verified by content hash, not by size or status code.

4. Search across pages and documents

A static site has no search server, and the most useful content was locked inside PDFs, so search had to be built at compile time and taught to read documents.

  • Index at build: Pagefind 1.5 indexes the rendered HTML during postbuild, producing a static index with no external search service to run or pay for.

  • Extract document text: a finalisation step pulls text from PDFs and Office files using pdf-parse, mammoth, officeparser and xlsx, writing one record per document into a manifest shaped to match Pagefind's addCustomRecord input.

  • Point results at the live file: each document record carries its CDN URL, so a search hit inside a PDF links to the document itself rather than to a page that mentions it.

  • Deletion does not undo indexing: removing a document from the CMS leaves its record in the generated index, so it keeps appearing in site search. Retiring a document means purging the record from the manifest as a separate, deliberate step.

5. Operating two CMS environments

Every document has to exist in both a staging and a production CMS, and the tooling targets whichever one the local configuration points at — which makes a stale setting an easy way to publish to the wrong place.

  • Confirm the target before every write: I check the configured authoring endpoint before each upload or rename, because the scripts carry no visible indication of which environment they are about to modify.

  • Move paired settings together: the authoring endpoint and the CDN host must change as a pair. On one occasion only the endpoint was switched, which was harmless for an uploader that merely prints a URL but would have caused environment-crossed links to be recorded by any script that stores them.

  • Verify on the host that actually serves the public site: the built site resolves documents against the production CDN, so a successful staging upload proves nothing about what members can reach.

  • Batch bulk changes, publish once: for a 24-item rename I replaced 24 invocations of the single-item tool — each of which re-listed all 7,101 documents and published separately — with one batch pass that lists once, renames all, and publishes once, behind a pre-flight asserting every source name was unique and every target free.

6. Verification discipline

Because so many failures here are silent, the habit that mattered most was refusing to accept a success message as evidence. Several of the rules below exist because I got caught first.

  • Check with GET, never HEAD: the CDN returned 404 to HEAD for freshly published items that served correctly over GET. I once reported three documents as missing from production on the strength of a HEAD probe; they had been live for seven weeks, and the real defect — two links pointing at the previous year's edition — was something I only found by opening each file and reading its cover page.

  • Verify bytes, not status: a stale cached document still answers 200, so replacements are confirmed by matching the served byte length and SHA-256 against the source file on both environments.

  • Guard destructive edits: bulk text edits assert the expected content at each boundary line before writing, and I keep a pre-edit copy so a failed guard leaves the file untouched.

  • Watch for invisible file damage: a sed edit silently converted a file from CRLF to LF, turning a one-line change into a 559-line diff. I caught it from the file size — exactly one byte per line — and now check line endings and diff size on every scripted edit.

The outcome

The site was rebuilt and is maintained in production, with the migration reducing hand-written work to a review pass on an estimated 10–15% of pages. I want to be straight about the figures: this project tracked completeness and correctness, not speed, so the honest headline is coverage rather than performance.

  • Full coverage: 553 source addresses became 510 static pages, with 411 documents migrated into the new content system and 6,318 internal links machine-checked against the known routes.

  • Search that reads documents: members can now find text inside PDFs and Office files from the site's own search box, which the previous platform could not do at all.

  • Operations that hold up: content changes ship several times a month across two environments, each verified against the public CDN before being called done — including a 24-document bulk rename completed with no broken links on either environment.

  • The honest limitation: no before-and-after performance measurement was ever captured. Lighthouse is wired into the pipeline but was never run, so while a static export should comfortably outperform the old platform, I have no numbers to prove it and will not claim any. Baselining that is the obvious next step.