Every month, before the 1st, the same race happened at Fullscript. Someone on the merchandising team filled a spreadsheet with the month's catalog content: categories to feature, products to spotlight, menus to refresh. An engineer then translated that spreadsheet into edits across a pile of JSON files, swapped the images, and got it all reviewed and deployed before the deadline.
We call this content playbooks, and it had grown to 105 JSON files. Every one of them was code.
That was deliberate. Playbooks started as an experiment, and JSON files in the codebase were the cheapest way to learn whether curated merchandising content moved the needle, before building any infrastructure for it. The right call, until the experiment succeeded. A year of monthly refreshes later, the scrappy setup was the bottleneck.
This year we moved it all into a CMS built on Payload: without rewriting the application that consumes the content, with zero downtime, and in about a month with AI coding agents doing much of the heavy lifting. The trick was treating the JSON the app already consumed as a contract, and refusing to break it.
What playbooks are, and why they matter
Playbooks are the merchandising layer of our self-serve catalog: the themed, regularly refreshed content that shapes what practitioners and patients discover. They power:
- Category pages, curated landing pages for each health category
- The mega menu, the catalog's navigation tree with its discover and featured sections
- Typeahead, the trending topics, top searches, and featured products inside the search box
These surfaces are a discovery funnel, and the funnel converts. A refreshed, playbook-driven navigation menu drove a sharp rise in menu engagement in experiment. Swapping one underperforming search banner for two featured product slots produced a double-digit lift in product views, and curated explore tiles on the native home screen lifted tile-to-cart conversion by even more. Content here is a revenue lever, and how fast you can change it determines how often you can pull it.
It changes constantly, too. Every month ships a themed refresh (cognitive support in January, men's health in November, immune support in the fall), coordinated with education campaigns and email journeys. Between refreshes come promo banners, product rotations, and out-of-stock fixes. It's a content operation with a marketing calendar.
The challenge
The content lived in code but changed like content. One engineer shipped the monthly cycle by hand for over a year, with the failure modes you'd expect: copy/paste errors, wrong image sizes, missed takedowns. Even images were compiled into the frontend build, so a banner swap needed a developer and a deploy.
Every change was an engineering task. A product swap on a category page: merge request, review, deploy. A missing product in typeahead: same. The people who owned the content couldn't touch it, and the running joke ("free us from the shackles of playbooks!") had a real cost: engineering days every month spent transcribing content.
The content wasn't simple. Each file wraps a config envelope (priority, activation window, audience scopes) around content that references real products, brands, and categories by SKU, slug, and name. Navigation trees run over 2,000 lines. One country's category-pages file was about 4,000. Reviewing a diff against that is a leap of faith.
And there was no preview. The only way to see next month's content rendered was to merge it. The merchandisers, the people best placed to catch mistakes, never saw content before it shipped.
The solution
An architecture worth keeping
The playbook engine was well designed from the start, around one principle: context is inferred, not passed. The frontend never tells the backend "I'm a patient in the US, on the web." It just asks for content. The backend infers role, country, catalog, and platform in one central place, filters candidate records by scope and date, sorts by priority, and returns the winner. Who sees what stays server-side, in one well-tested spot.
It's also uniform: all ten content types share the same envelope, validation, and filtering, so adding a surface is a recipe, not a project. That uniformity is why the migration was even feasible. A system with one seam migrates far more easily than ten bespoke ones.
The seam
The application never read the JSON files directly; it read playbooks through a small repository interface, and the file-backed reader was one implementation. We built a second one that fetches from the CMS over GraphQL. Everything downstream (validation, filtering, sorting, parsing, the API the frontends use) stayed byte-for-byte untouched.
Filtering stayed local on purpose. The CMS returns all published records for a content type; the app filters per request. That logic already existed and was well tested, and one globally cacheable payload beats per-user cache keys.
Modeled for humans, serialized for machines
In Payload, each playbook type is its own collection with the same scope-and-dates envelope. The authoring experience was rebuilt around the authors:
- Pickers, not strings. Merchandisers search and select real products, brands, and categories instead of hand-typing SKUs and slugs. The delivery layer serializes selections back into the exact names and SKUs the app already parses. That JSON shape is the contract, pinned with shared fixture tests in both codebases.
- Uploads, not build assets. Images moved to CMS-managed media with dimension validation, so the wrong-sized-banner error fails at authoring.
- Small records, not giant files. The 4,000-line category-pages file became one record per page, each reviewable and schedulable on its own.
- The spreadsheet survived. A CSV import turns each planning row into a draft record and reports anything that doesn't resolve. The product manager's live reaction: "that's amazing, and it tells you what skipped."
Review, preview, scheduling
Every collection gets a draft, review, and publish flow. The best part is preview: every record carries a link that opens the app on staging with that record pinned to win selection, even while it's still a draft and even if its start date is weeks in the future. Authors see next month's content rendered by the real app, against real catalog data, before anything goes live. That confidence is the point: there are no blind publishes anymore. Broken references render visibly instead of failing silently.
Scheduling has two layers: publishing controls when a record exists in the API, and the activation window controls when users see it. Takedown needs no job and no human. Expired content just stops being selected. The app pulls published content over GraphQL and caches it in Redis per region, with invalidation every 24hours. The CMS is the source of truth, not a runtime dependency.
Migration with agents doing the digging
AI coding agents drafted the migration plan (one pass over the playbook engine, one over the CMS) in days instead of weeks of code archaeology, then generated the importer and the contract tests. Engineers reviewed, corrected the modeling, and owned every merge.
A one-time, idempotent importer moved all 105 files: envelopes to scope fields, hand-coded references to real CMS relationships, images to uploads, source filename kept on every record. Cutover was per content type behind flags, gated by a parity check against the committed JSON, with the file reader as live fallback. No deploy, no downtime. Design review to full production migration was done in just a couple weeks, with two engineers working alongside their regular sprint load.
Results & impact
The recurring engineering tax is gone, and the hand-shipped monthly cycle with it. A merchandiser authors, previews, submits for review, and schedules for the 1st. Mid-month changes went from "ticket, merge request, deploy" to minutes of self-serve editing.
Faster iteration on surfaces that convert. Content now moves at the speed of the marketing calendar. The team that measured double-digit lifts from single content changes can iterate without engineering in the loop.
Content became reviewable. The unit of review went from a multi-thousand-line JSON diff to one record with a visual preview and version history.
The application was barely noticed. The content contract never changed, so the consuming code and its tests went through the migration untouched.
One thing bit us. A behavioral gate existed only in the old files: a config flag whose absence meant "allow" in the new pipeline. For several days a subset of web users saw the wrong menu variant, caught visually because nothing errored. Worse, the bug had been found and fixed during testing, then silently undone by a re-run of the importer, which owns every field it writes. Both lessons are in the pitfalls below.
How any team can implement this
- Find or create the repository seam first. Then migration is swapping an implementation.
- Make the old output format the contract. The consumer not noticing is the success criterion.
- Migrate with an idempotent importer that reports instead of aborting. You will run it more than once.
- Cut over per type behind flags, gated by parity checks. Keep the legacy path as a fallback until the new one is boring.
- Migrate behavior, not just content. A gate whose absence means "allow" fails open, and failing open looks like success until a human looks at the page.
Pitfalls: manual fixes to imported data are temporary, because the importer owns those fields; log silent selection fallbacks, they are how a wrong page ships without an alert; and make preview mandatory in review, since it's the only step where a human sees what users will see.
Conclusion
A year ago, playbooks were a monthly deadline held together by a spreadsheet, one careful engineer, and 105 JSON files. Today they're CMS records with pickers, preview, review, and scheduling, read through the same interface the app always had. The migration took about a month and handed a proven revenue lever to the team that actually pulls it.



