CMS
Convex-native content layer powering per-tenant structured content, blocks, and editor access.
The CMS is the Convex-native content layer. Content rows live in the Convex
deployment (packages/convex); the field descriptors, editor primitives, block render
layer, and cache taxonomy that give those rows meaning are packaged as
@nordcom/commerce-cms. It is multi-tenant by construction: every content table keys
on the shop's id (shop == tenant — there is no separate tenant record to create or
sync).
Content is read by the Storefront through the Convex cms/read functions and
edited via the Admin app at /cms.
The CMS was built on Payload 3.x before the Convex migration. The
.specs/2026-05-30-convex-migration/ archive in the repo is the historical record
of that cutover.
Descriptors and codegen
Content shapes are declared once, as field descriptors
(@nordcom/commerce-cms/descriptors). pnpm cms:gen derives every downstream
artifact from them: the admin's editor-action wrappers, the storefront's
read-contract types (content-types.ts), and the Convex content-table validators
(packages/convex/convex/tables/cms.ts). CI fails on drift via pnpm cms:gen:check,
so the editor, the read contract, and the storage schema can never disagree.
Blocks and block loaders
Structured content is modeled as blocks — discriminated-union content units defined in
@nordcom/commerce-cms/blocks. Blocks are generic; the CMS package has no Shopify dependency.
Some block types need live Shopify data at render time (a Featured Product block needs current pricing; a Collection block needs inventory-aware product listings). To keep the CMS package free of Shopify dependencies, those blocks declare a data contract, and the Storefront supplies a block loader at the rendering boundary.
import type { BlockLoaders } from '@nordcom/commerce-cms/blocks/render';
import { getProductByHandle } from '@/api/_loaders';
export const cmsLoaders = {
product: async ({ shop, locale, handle }) => getProductByHandle({ shop, locale, handle }),
collection: async ({ shop, locale, handle }) => getCollection({ shop, locale, handle }),
} satisfies BlockLoaders;The BlockRenderer in the storefront receives this loader map and dispatches to the
correct loader when it encounters a block type that declares a Shopify data need. The
CMS package never imports from @shopify/* directly.
When adding a new block type that needs live data, define the data contract in
@nordcom/commerce-cms/blocks and implement the loader in apps/storefront/src/cms-loaders.ts.
The TypeScript types enforce that the loader map is complete — a missing loader is a
compile error.
Tenancy and access
Every CMS content table carries the shop id as its leading index (by_shop), and the
Convex functions are built with tenant-aware constructors — a query without a resolved
tenant never returns documents from another shop. Access layers on top of this:
- Admins (platform-level) see all tenants, all drafts.
- Editors (per-tenant role) see only their shop's documents; drafts only while editing or previewing.
- Public reads (the storefront's
cms/readcalls) see only published documents for the resolved tenant.
The editor's route-level gates live in the collection manifests
(@nordcom/commerce-cms/editor/manifests).
Cache integration
Publishing a document runs the Convex revalidation pipeline
(packages/convex/convex/revalidate/), which computes tags from the shared cms tag
schema and delivers signed events to the storefront's /api/revalidate/convex route.
A single CMS publish flushes exactly the per-tenant, per-collection, per-handle tags
the storefront read — nothing more, nothing less. See
Caching for the full tag schema.