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.

Concept

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.

apps/storefront/src/cms-loaders.ts
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.

Tip

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/read calls) 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.

Continue exploring

On this page