Ezi Home Services turns marketing pages into composable content
Ezi's city marketing pages were hardcoded across a routes file and half a dozen override files — any copy change meant a code deploy. Moving them to Draftbase meant treating MDX as a layout language: editors place named section tags, each tag resolves to a real component bound with live app data at render time.
Updated
123
Pages migrated (6 cities, 53 areas, 64 city+service)
3
Page tiers, one binding pattern
13
Production issues caught and fixed
0
Deploys needed to edit copy
Why a rich-text CMS didn't fit
The pages aren't free-form prose — they're a sequence of structured components (reviews carousel, services grid, FAQ accordion, before/after slider) that each need live data: current service catalog, real reviews, nearby areas. A "CMS renders markdown to HTML" model doesn't fit a page that's composed UI, not an article.
<ReviewsSection heading="What Ottawa Homeowners Are Saying" /> <AvailableServicesSection title="All Home Services in Ottawa" /> <CityAreasSection heading="Areas We Serve in Ottawa" /> <HowToBookSection title="How Our Home Cleaning Services Work in Ottawa" />
Each tag maps to a component already in the codebase. The binding layer (cmsCityMdxComponents.tsx) injects server-computed context — which city, which live services — that an editor should never have to supply by hand.
Scope
| Tier | Route | Editor controls |
|---|---|---|
| City pages | /cities/[city]/ | Hero, all 10 section headlines/subheadings, FAQ, "why choose us" points, booking steps |
| Neighborhood pages | /cities/[city]/[area]/ | SEO title/description, area description |
| City+service pages | /cities/[city]/[category]/[optionalCost]/ | Section presence/order — replaced ~245 lines of runtime per-city conditionals |
Rolled out in order: pilot on Ottawa's city page, then the remaining 5 cities, then the area template for all 53 neighborhood pages, then the cityService template for all 64 city+service combinations. Nav (buildCityMenu) and breadcrumbs were switched to read the same CMS fields the page renders, closing a gap where they could otherwise silently drift from an editor-changed service catalog.
Decisions that kept it maintainable
MDX as layout, not content
An entry body is a sequence of tags like <ReviewsSection heading="..." />. Presence, absence, and order are the only structural levers an editor has — each tag resolves to a real, pre-existing React component, not a CMS-specific renderer.
Error isolation per section, not per page
Every MDX-bound component sits inside a shared SectionErrorBoundary at the binding layer. One section throwing logs to Datadog and renders null, instead of taking the whole page down via error.tsx.
JSON-string props for structured content
MDX component props only support string | number | boolean, so FAQ lists, "why choose us" points, and booking steps are authored as one JSON-encoded string prop and parsed inside the binding function — one escape hatch, reused everywhere instead of new field types per shape.
Additive-only props
Every new CMS-controlled prop is optional and falls back to the component's existing computed default, so non-CMS callers stay unaffected — a hard constraint, not an incidental choice.
Problems hit along the way
Server/client boundary violations. Several section components had no 'use client' of their own — they'd always been rendered from an already-client parent, so they "worked" by inheriting client-ness, not because React does that automatically. Once a server component (MDXContent) rendered them directly, any client-only hook inside threw at render time. Fixed by having each component declare its own boundary.
Functions can't cross the server/client boundary as props. A server-computed closure was being passed into a client component; React allows JSX across that boundary, not functions. Fixed with a small client wrapper that receives only serializable data and builds the closure itself.
Stale in-memory and disk caches masking real publishes. The SDK client caches successful reads for cacheTtlMs (5 minutes) — a manually-published entry 404'd until that cache expired. Separately, a route's 5-day revalidate window had cached a 404 to disk from before an entry existed, which survived a full process restart. Diagnosed by ruling out each layer in turn rather than re-checking the same one.
Templated copy isn't unique copy. Early overrides just substituted the city name into a generic template — technically CMS-controlled, but not yet actually distinct per city. Every section wasn't counted "migrated" until an entry had real, authored copy, not a prop plumbed through to the same fallback every other city would also hit.
"Migrated" and "dead" aren't the same predicate. Once all 6 city pages read from the CMS, the old CityPageView.tsx fallback was unreachable — but WhyChooseUs and FAQSection's default copy were still consumed by the not-yet-migrated area pages. Every symbol's full consumer list was grepped before deleting anything; ~200 lines of exclusively-consumed content came out, everything still load-bearing stayed.
Outcome
All 6 city pages, all 53 neighborhood pages, and all 64 city+service pages are CMS-driven — hero, section copy, FAQ, and structured content blocks all editable without a deploy. Two rendering bugs were caught by the error-boundary design during the pilot rather than shipping broken. Replicating the pattern surfaced a stale FAQ answer referencing services two cities no longer offered, and required real per-city writing for 24 sub-service cards that had no bespoke copy to begin with — a purely mechanical migration would have carried both forward unnoticed.
Composing pages from live components
Bind MDX tags to your own React components with the SDK's compileMDX, and let editors control presence and order without touching code.
Hobby is free, no card. Startup is $49/mo when you outgrow it. The price is on the pricing page, where prices go.
No migration quarter, no kickoff workshop. Define a template and ship something today.