Field note 33/ Headless CMS
We Replaced the Library That Broke Deployment With One That Broke Publishing
A multi-site CMS pipeline worked locally, failed in serverless, then failed again at publish time. Here is what the full lifecycle taught us.
Our publishing pipeline worked locally. On the deployed server, every blog route returned a 500 before the publishing code could do anything useful.
The first fix was obvious: pin the Node runtime. That did not fix it.
The second fix looked better. We replaced the DOM library that was crashing during serverless module loading. The routes deployed. The application opened. Then every real publish failed because the replacement did not implement an API the content converter called internally.
We had traded a deployment failure for a publishing failure.
This is the part of CMS automation people tend to skip. Generating a Markdown file is easy. Publishing it across several sites means dealing with runtime packaging, content conversion, schema differences, permissions, remote assets, cache invalidation, partial failure, scheduling, and deletion order.
What we were actually building
We wanted one review and publishing workflow for content headed to multiple websites. A writer or client could prepare a draft in the dashboard. An internal reviewer could edit it, approve it, schedule it, and publish it into the correct CMS project.
That sounds like a form with a Publish button. The real path was longer:
- Store the draft and its publishing state.
- Convert Markdown into the structured content format expected by the CMS.
- Download and re-upload referenced images.
- adapt the document to the target site's schema.
- create or replace the remote document.
- update the local publishing record.
- ask the website to invalidate its cached page.
- preserve enough state to reconcile the two systems later.
A bulk import added another branch. It had to accept an archive, resolve each draft to the correct project, validate frontmatter, isolate bad files, and make retries harmless. Scheduling added another. Unpublishing and deletion added another.
The first version included most of those pieces. That was useful, but it also meant a dependency used deep inside Markdown conversion could take down routes that appeared unrelated to conversion.
“Works on Node” is not a runtime contract
The converter turns Markdown into HTML, creates a DOM document, and passes that document into Sanity's Portable Text tooling. The tooling walks the document and produces structured blocks for paragraphs, headings, links, images, tables, and other content.
Locally, the original DOM library handled that job.
In the serverless build, a newer version pulled in a transitive ESM-only dependency. The deployed bundle tried to load that dependency through a CommonJS path. Module initialization failed, and the route returned a 500 before the request reached our publishing logic.
We initially pinned the deployment to Node 22 because the dependency chain expected newer module behavior. Reasonable guess. Wrong layer.
The serverless bundler still packaged and loaded the dependency in a way that hit the same boundary. Changing the runtime did not change how that module entered the bundle.
The failed fix exposed three separate contracts:
- The Node version tells you which language and module features the runtime supports.
- The bundler decides which files get bundled, externalized, transformed, or loaded dynamically.
- A package's transitive dependencies decide whether those assumptions remain compatible.
All three can be valid separately while the deployed combination still fails.
A unit test that calls the converter under plain Node will not catch a serverless module-load failure. A successful build may not catch it either if the failing route is loaded lazily. The useful test is a deployed request that forces the real route to initialize and performs the conversion path.
The replacement deployed and still did not work
We replaced the DOM implementation with a lighter alternative. The serverless routes loaded again, but publishing still failed.
The Portable Text converter calls document.evaluate() during preprocessing. That is an XPath API. The replacement DOM library did not implement it. The route now initialized correctly, but conversion crashed as soon as it handled a real document.
This failure was less dramatic and more instructive. The replacement had the APIs we used directly: document creation, query selectors, attributes, and HTML serialization. It missed the surface our dependency used.
The replacement could parse HTML. That was insufficient. Compatibility had to include every DOM behavior exercised by the complete publishing path.
The fix was an older version and a more explicit boundary
We returned to the original DOM library, but not its newest release. We pinned an older compatible version whose dependency chain still loaded correctly in the deployed environment and retained the XPath behavior the converter required.
Then we told the application bundler to treat that package as external server code rather than folding its dynamic and optional imports into the serverless bundle.
That solved the runtime side of the failure. A separate schema problem was waiting behind it.
The content converter compiled a small standalone schema for Portable Text. We initially referenced Sanity's built-in image type. In a full Sanity project, that type brings along its normal supporting definitions. In the standalone converter schema, those definitions were not present, so the compiler could not resolve the image fields it expected.
The fix was to define the minimal image object the converter needed, tag it as an image block, and attach the uploaded asset reference later in the publishing pipeline.
That change clarified the boundary. The converter's job is to identify an image and preserve its source information. The publisher's job is to fetch the asset, upload it into the target project, and replace the migration field with a real CMS reference.
Trying to make the standalone converter pretend it was the full CMS schema had created an unnecessary dependency on types it did not own.
Publishing exposed a separate permission bug
Once conversion worked, the remote CMS document could be created. The local database still rejected the transition from draft to published.
The publishing action correctly required an internal publishing permission. After that check, trusted server code used an administrative database client to perform the remote publish and update the local record.
A database trigger enforced a sensible rule: client users could create and edit drafts but could not publish them. The trigger identified internal users through the authenticated user's ID.
The administrative client did not carry that end-user identity. From the trigger's perspective, the update was neither an internal user nor an allowed client transition. It rejected the same backend publish path that the server action had already authorized.
The trigger change was narrow: keep the client restriction and explicitly allow the trusted service role used by server-side publishing. Client traffic continued through scoped connections. The service credential remained server-only.
The permission rule was sound for client traffic, but its identity model omitted trusted service execution.
The application had two authorization layers with different ideas of who was acting:
- the server action knew which person requested the publish;
- the database saw the privileged service process executing it.
If those layers do not agree on the handoff, a valid operation looks unauthorized. The opposite mistake is worse: a privileged backend quietly bypasses a restriction nobody intended it to bypass.
We now include service identity explicitly in the publishing contract.
One CMS schema became several dialects
The first target sites looked similar because they used the same CMS. Their document schemas were not identical.
One expected a publication date under one field name; another expected a different field. Image fields differed. Author representation differed. Read time could be a formatted string in one project and a number in another. Some body block types were valid in the standard schema but unsupported by a legacy one.
The wrong response would have been a growing set of conditions scattered through the publisher:
if (project === "site-a") { /* ... */ }
if (project === "site-b") { /* ... */ }
That turns every new site into a regression risk for every existing site.
We added an explicit schema dialect to each integration. The common publisher builds one normalized document. A dialect adapter transforms that document for a target that genuinely differs. Unsupported blocks fail with a useful error before remote mutation.
The adapter is intentionally small. If every project eventually gets its own dialect, we have stopped building a multi-site CMS and started maintaining a collection of custom exporters.
Batch publishing has to assume partial failure
The bulk importer does not treat an archive as one giant transaction. It records a job, creates an item for each Markdown file, and tracks success or failure per item.
That allows the useful behavior:
- one invalid draft does not discard the other valid drafts;
- retrying a failed import does not create duplicate posts;
- an unresolved project produces a specific item error;
- archive size is bounded both before and after decompression;
- remote revalidation accepts only safe site targets;
- publish results are returned per post.
We also publish sequentially. Parallel publishing would look faster in a benchmark, but this workflow uploads remote assets, shares an in-run asset cache, mutates two systems, and triggers downstream revalidation. Predictable pressure and clear outcomes matter more than shaving a few seconds off an editorial batch.
The same principle applies to scheduling. A scheduled post is not successful because a timestamp exists in the database. The scheduled worker has to publish the remote document, record the local transition, trigger revalidation, and leave an outcome someone can inspect.
A queue full of “scheduled” rows is not evidence that anything became public.
Delete remote content before the source record
Deleting a published item from the dashboard before deleting the remote CMS document leaves an orphaned article on the website. Deleting remotely first can also leave the local record in an awkward state if the local delete then fails.
We chose the safer visible failure: unpublish the remote document first, trigger site revalidation, and only then remove the local record. If remote unpublishing fails, the dashboard keeps the record and reports the error.
That is not a distributed transaction. There is still no atomic commit across the database, CMS, asset service, and website cache. It is an ordered workflow with a recoverable source record.
Reconciliation fills the gap. The dashboard can compare locally published rows against remote document IDs and report content that is missing on the CMS side. The system keeps the identifiers needed to repair drift instead of assuming two successful API responses can never diverge later.
How we test a multi-site CMS publishing workflow now
A useful test matrix follows the lifecycle rather than the module boundaries.
Deployment
- Does the production route initialize under the real serverless bundler?
- Can it load the converter and its transitive dependencies?
- Does a deployed request exercise the code rather than stop at a health response?
Conversion
- Do headings, links, images, tables, and code blocks produce valid structured content?
- Does the parser support the DOM APIs used by downstream libraries?
- Are unsafe links and table HTML sanitized?
Target adaptation
- Does each supported schema dialect receive the correct fields and types?
- Does unsupported content fail before remote mutation?
- Are new sites using the standard schema unless an exception is proven necessary?
State and permissions
- Can a client save a draft without gaining publish access?
- Can an authorized server action publish through the service identity?
- Do local and remote records keep the same stable document ID?
Failure and recovery
- Can one failed item leave the rest of a batch intact?
- Is retrying harmless?
- Can the system find remote/local drift?
- Does deletion preserve the source record when remote cleanup fails?
A test that stops at “the function returned success” misses most of the interesting failures.
When we call the integration finished
Creating a document is one step. The integration is finished when the deployed system can import, convert, adapt, publish, schedule, revalidate, unpublish, retry, and reconcile without hiding which step failed.
That standard is annoying. It is also why the next content batch can be routine instead of a production debugging session.