CMS & Marketing: Content, Curation, and Location Pages
Four subsystems make up what a merchandiser or marketer actually touches day to day: CMS (page content), marketing catalog (the standing category taxonomy), search (finding products), and service areas (location-based marketing pages). All four share one organizing idea that shows up repeatedly across this framework: data and page layout are different subsystems, on purpose. A category is data (marketing catalog); how a category page looks is theming’s job. A service area is data (service areas); its location page is CMS. That split is what lets a deployment restyle every category page at once without touching a single category record, and re-curate a category without touching layout code at all.
CMS: five page types, one shared component vocabulary
The CMS subsystem owns page content — not layout, which belongs to
theming — for five page types the framework’s founder named
explicitly: home, category, marketing, search, and PDP, plus a sixth (location) added later
for service-area pages. Every page type shares the same underlying shape: a page is a title, a
status (draft or published), and an ordered list of sections — content-authored
component instances that reference a component type and a config blob, not hardcoded markup:
// packages/cms/src/types.ts
export type PageType = "home" | "category" | "marketing" | "search" | "pdp" | "location";
export type PageStatus = "draft" | "published";
/** A content-authored block a page references by type + config -- the composable building block every page type's content is made of. */
export interface ComponentInstance {
componentType: string;
config: Record<string, unknown>;
}
export interface Page {
id: string;
pageType: PageType;
/** For "home", a fixed slug; for marketing pages, campaign-specific; category/search/pdp pages are typically keyed by their own domain id instead of a CMS slug. */
slug: string;
title: string;
status: PageStatus;
sections: ComponentInstance[];
}Because sections is just an array of { componentType, config }, adding a brand-new component
type is never a change to Page itself — it’s a new entry in the component registry:
// packages/cms/src/component-registry.ts
export const DEFAULT_COMPONENTS: ComponentDefinition[] = [
{ type: "hero-banner", label: "Hero Banner", description: "A large top-of-page banner with image/copy/link." },
{ type: "ad-slot", label: "Ad Slot", description: "A promotional placement." },
{ type: "category-spot", label: "Category Spot", description: "A featured-category tile linking to a category page." },
{ type: "product-grid", label: "Product Grid", description: "A grid of products -- reused across category/marketing/search pages." },
{ type: "service-area-info", label: "Service Area Info", description: "Contact/hours/local marketing copy for a location page (subsystem 15) -- the structured phone/name/region live on the ServiceArea entity itself, this component is for richer authored content." },
];product-grid is a good example of the “shared component vocabulary” idea: the exact same
component type renders a category page’s product listing, a marketing page’s curated set, and a
search page’s results — theming decides how it’s laid out, CMS just says “there’s a product
grid here, with this config.”
Here’s a real seeded home page — the actual seed data the print-shop demo storefront loads on
first run:
// apps/reference-storefront/lib/seed.ts
const home = await cms.createPage({
pageType: "home",
slug: "home",
title: "Home",
sections: [
{
componentType: "hero-banner",
config: {
headline: "The Print Shop",
subheadline: "Embroidery, custom coasters, apparel, and drinkware -- personalized to order. New drops every season.",
},
},
{
componentType: "category-spot",
config: { categorySlugs: ["embroidery", "custom-coasters"] },
},
{
componentType: "ad-slot",
config: {},
},
],
});
await cms.publishPage(home.id);That’s the whole authoring model: a hero, a couple of featured categories, and an ad slot,
described declaratively and rendered by whichever layout template theming currently has
registered as the default for the home page type.
Marketing pages: campaign content with its own mini-catalog
A CMS marketing page is deliberately distinct from a standing category — it’s a time-boxed campaign (a seasonal sale, a Halloween special) with its own curated, ordered product list that CMS owns directly, not a reference into the category taxonomy:
// packages/cms/src/types.ts
export interface MarketingPageMeta {
pageId: string;
campaignName: string;
/** ISO date string. */
startDate: string;
/** ISO date string, or null for an open-ended campaign. */
endDate: string | null;
/** Ordered, curated -- NOT a set. Display order is authoring intent. */
productIds: string[];
}And the real seed data creating one, immediately after the home page above:
// apps/reference-storefront/lib/seed.ts
const toteId = productIdBySlug.get("embroidered-canvas-tote");
const { page: campaign } = await cms.createMarketingPage({
slug: "fall-sale",
title: "Fall Sale",
sections: toteId ? [{ componentType: "product-grid", config: { productIds: [toteId] } }] : [],
campaignName: "Fall Sale 2026",
startDate: "2026-10-01",
// ...
});Notice the campaign’s sections (a product-grid component instance) and its
MarketingPageMeta.productIds are two different lists serving two different purposes: sections
say what renders on the page, while the meta’s productIds is the structured, queryable
curation data — the thing an AI/MCP tool or an admin report could read without parsing page
content. createMarketingPage writes both atomically:
// packages/cms/src/service.ts
async createMarketingPage(input) {
const page: Page = {
id: randomUUID(),
pageType: "marketing",
slug: input.slug,
title: input.title,
status: "draft",
sections: input.sections,
};
await pages.save(page);
const meta: MarketingPageMeta = {
pageId: page.id,
campaignName: input.campaignName,
startDate: input.startDate,
endDate: input.endDate,
productIds: input.productIds,
};
await marketingMeta.save(meta);
return { page, meta };
}Marketing catalog: the standing taxonomy catalog itself never knows about
The marketing catalog subsystem is what the framework’s
founder called the missing piece in every free/OSS commerce option evaluated before building
this one: a real category taxonomy — “Toys → Kids → Legos” — layered on top of the product
catalog, without the product catalog ever knowing categories exist. Product-to-category
assignment is a many-to-many join owned entirely by this subsystem, not a field on Product
itself:
// packages/marketing-catalog/src/service.ts
export interface MarketingCatalogService {
createCategory(input: NewCategoryInput): Promise<Category>;
listChildCategories(parentId: string | null): Promise<Category[]>;
assignProductToCategory(productId: string, categoryId: string): Promise<void>;
unassignProductFromCategory(productId: string, categoryId: string): Promise<void>;
listCategoriesForProduct(productId: string): Promise<Category[]>;
listProductIdsInCategory(categoryId: string): Promise<string[]>;
suggestCategories(productId: string): Promise<CategorySuggestion[]>;
}suggestCategories is assistive, not authoritative — it looks at a product’s full attribute map
(from catalog) and suggests likely categories a merchandiser can accept or override, never an
auto-assignment a human can’t see coming. The subsystem’s own decoupling test is the cleanest
one in the repo: delete marketing catalog entirely and the product catalog still works, you just
lose browsing and curation — never product data integrity.
Search: a read-optimized copy, kept in sync by events
Search is the page the founder flagged as “a big one” — query, filter,
and facet behavior over products, backed by a pluggable index adapter (a zero-infra default
ships in-repo; Solr/Elasticsearch/Algolia/Meilisearch are swap-ins). The important architectural
choice is that search never queries catalog directly at request time. It maintains its own
denormalized index, kept current purely by subscribing to catalog.product.* and
catalog.sku.* events — the same event-bus decoupling this framework leans on everywhere else,
applied here so that swapping search backends is a config change, not a data migration. Facet
configuration is driven by catalog’s own ProductAttribute.facetable flags rather than a
separate, hand-maintained facet format.
Service areas: location pages for a business that isn’t shippable-anywhere
Service areas exist for a real, concrete need: a business like
home-automation installer All That Technology operates in specific cities, not “anywhere in the
US,” and needs a real page per location — not a shippable-goods category. A ServiceArea is a
standing, structured entity (slug, name, region, phone) with its own many-to-many
product assignment, mirroring marketing catalog’s category-assignment join exactly:
- Data lives on the
ServiceAreaentity (queryable — “what’s the phone number for the Royse City location” is a real question an AI/MCP tool can answer directly from structured data). - Page rendering goes through CMS’s
locationpage type, using the samesections/ component-instance model as every other page type — richer marketing copy (hours, a map embed) belongs there, not on the structured entity.
The Northline reference demo makes this real rather than illustrative: its seed data
(apps/reference-storefront/lib/seed-northline.ts) assigns “Fiber Internet Installation” to
only 5 of 8 seeded service areas (the ones with fiber infrastructure) while “TV Wall Mounting”
is assigned to all 8 — and each of those 8 areas has its own published CMS location page whose
services-offered list is computed directly from that join, never hand-typed per page.