Skip to main content
Version: next

Create custom sections

Sections are the building blocks a content manager assembles into a page. This guide explains what a section is made of and how to add your own.

Adding a section type takes three steps: define its editable fields, render them with a React component, and register the section so pages can use it and the editor can offer it. This guide walks through each.

Using an AI coding assistant?

Copy the prompt below and paste it into your agent of choice (Claude Code, Cursor, etc.). It will guide the agent through every step of this page and scaffold a complete, tested CMS section for you.

What a section is made of​

A section has two halves that share the same type:

  1. A definition — its metadata (label, icon, category, defaultProps) and a schema describing the editable fields (see Section field types for every available type).
  2. A React component — registered in the CmsSection composition scope and receiving the section's props. The common options (background, spacing, visibility) are applied around it automatically, so your component only renders its own props.

Built-in sections can be used as reference.

Define the editable fields​

A section is described by its composition metadata: a sibling file next to the component, named <Component>.CompositionMetadata.ts, that exports <Scope>CompositionMetadata — for the CmsSection scope, CmsSectionCompositionMetadata. It declares the picker entry (label, icon, category), the defaultProps a freshly added section starts with, and the schema — the editable fields and the editor control each one uses:

app/cms/sections/Callout/Callout.CompositionMetadata.ts
import type { CmsSectionMetadata } from "@front-commerce/cms";

export const CmsSectionCompositionMetadata = {
name: "Call to action",
description: "A heading, a short message and a call-to-action button",
icon: "📣",
category: "content",
defaultProps: {
title: {
text: "Need help?",
level: "h2",
size: "text-3xl",
weight: "font-bold",
alignment: "center",
},
message: "Reach out to our team.",
ctaLabel: "Contact us",
ctaUrl: "/contact",
},
schema: {
title: {
type: "title",
label: "Title",
default_value: {
text: "Need help?",
level: "h2",
size: "text-3xl",
weight: "font-bold",
alignment: "center",
},
},
message: { type: "textarea", label: "Message", default_value: "" },
ctaLabel: {
type: "text",
label: "Button label",
default_value: "Contact us",
},
ctaUrl: {
type: "link",
label: "Button link",
default_value: "/contact",
attributes: { placeholder: "https://…" },
},
},
} satisfies CmsSectionMetadata;

Each key in the schema selects an editor control for the properties sidebar; see Section field types for the full list. Every schema key maps to a defaultProps key and to a prop your component receives.

The metadata can also carry hidden: true — the way to deprecate a section without breaking existing content: the section stops appearing in the editor's picker, while the pages already using it keep rendering and editing it.

The metadata name is only the label shown in the picker — free text, unrelated to the section type (which you set when registering the component). The metadata is attached to its component by file path (the *.CompositionMetadata.ts sibling sitting next to it), never by name, so the two never need to match.

You don't wire any of this up by hand: CmsSectionMetadata is exported by @front-commerce/cms (which owns the CmsSection scope, so you needn't augment CompositionMetadataMap yourself), and the build collects every *.CompositionMetadata.ts sibling automatically.

Render the section​

The component is the part a visitor sees. It receives the section's props — the values of the fields you declared in the schema — and renders them:

app/cms/sections/Callout/Callout.tsx
type Heading = {
text: string;
level?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
};

export interface CalloutProps {
title?: Heading;
message?: string;
ctaLabel?: string;
ctaUrl?: string;
}

export default function Callout({
title,
message,
ctaLabel,
ctaUrl,
}: CalloutProps) {
if (!title?.text) return null;
const HeadingTag = title.level ?? "h2";

return (
<div className="callout">
<HeadingTag>{title.text}</HeadingTag>
{message ? <p>{message}</p> : null}
{ctaLabel && ctaUrl ? (
<a className="callout__cta" href={ctaUrl}>
{ctaLabel}
</a>
) : null}
</div>
);
}

You only render your own props. The renderer wraps each section and applies its common options for you — the section-level config (background, spacing, visibility) and style become the wrapper's inline styles, so you never handle them. It also passes _sectionId, _index, and _config to your component if you ever need them.

Keep the component lean and reuse your theme's own components — headings, buttons, and so on — so your sections stay visually consistent with the rest of the storefront. The complete example does exactly that.

Register the section​

Finally, register the component in the CmsSection content composition scope. This links the section type to your component — the same mechanism the built-in sections use — so the page renderer can resolve stored sections to it. Declare the composition, then register it from your extension's onContentCompositionInit hook:

app/cms/sections/index.ts
import { createContentComposition } from "@front-commerce/core";

// One composition holds every section your app registers — add an entry per
// section.
export const appSections = createContentComposition("CmsSection", [
{
// the entry `name` is the section `type`: a stable, unique id persisted
// backend-side and used to resolve this component. It is unrelated to the
// metadata `name` (which is only the picker label).
name: "CalloutSection",
client: {
component: new URL("./Callout/Callout.tsx", import.meta.url),
fragment: null,
},
},
]);
your extension's index.ts
import { defineRemixExtension } from "@front-commerce/remix";
import { appSections } from "./app/cms/sections";

export default function customCmsSections() {
return defineRemixExtension({
meta: import.meta,
name: "custom-cms-sections",
unstable_lifecycleHooks: {
onContentCompositionInit(composition) {
composition.registerComposition(appSections);
},
},
});
}

Now SectionRenderer resolves every section of that type to your component through CompositionComponent<"CmsSection">, so it renders wherever the type appears on a page. And because its metadata sibling is collected automatically, the section also shows up in the editor's "Add a section" picker (grouped under its category), with its schema driving the properties sidebar — no theme override required.

note

A component registered without a *.CompositionMetadata.ts sibling still renders wherever a page references its type, but it won't appear in the picker — it has no label, icon, or schema to show there.

Dynamic sections with data sources​

The sections above render only the values a content manager types. A dynamic section also displays live catalog data — a product, a category — chosen in the editor and resolved server-side. It builds on the same three steps, plus a GraphQL field:

  1. Store a key, not the entity. Give the section a datasource field. The editor shows a searchable picker for that source and stores the selected entity's key (an id, or a product SKU):

    ProductCard.CompositionMetadata.ts
    schema: {
    sku: { type: "datasource", dataSource: "product", label: "Product", default_value: null },
    title: { type: "text", label: "Title", default_value: "" },
    }
  2. Declare the resolved field. Add a GraphQL type implementing FCCmsSection that exposes the entity, and a co-located fragment selecting the fields your component needs:

    type FCCmsProductCardSection implements FCCmsSection {
    id: ID!
    type: String!
    props: JSON!
    config: JSON!
    style: FCCmsSectionStyle
    product: Product
    }

    The field resolves through the CMS data source registry keyed by the stored prop, so you write the resolver once and every backend that implements the product data source feeds it. A backend that does not implement the source resolves to null, and the section degrades gracefully — see which sources each flavor implements.

  3. Register the component with its fragment. Same registration as a static section, but point fragment at the section fragment (instead of null). The resolved entity is delivered to your component alongside its authored props — the renderer forwards it for you.

    Never give the resolved field the name of an authored prop

    The renderer hands your component its authored props and its resolved fields in a single object, resolved last. A field named like the prop it resolves from therefore only wins while the resolved data is there — and on every path that yields none (a failed preview request, a section beyond the preview's URL budget) the authored value silently takes its place, handing your component raw editor input where it expects an entity. Name the two apart: the built-in ProductList stores selectedProducts and resolves products.

  4. Declare preview in the metadata. This tells the editor that your section's data has to be fetched to preview it, and which placeholder to show meanwhile:

    ProductCard.CompositionMetadata.ts
    preview: { skeleton: { variant: "card" } },

    variant is one of block (the default), card, grid or list, and count (default 1) repeats the placeholder. Pick the shape closest to your section's real layout so the preview doesn't jump when the content arrives.

    When the placeholder should follow an authored option instead — a layout, a density — declare the skeleton as a function of the section's authored props (never its resolved dynamic data, which is exactly what the skeleton stands in for):

    MySection.CompositionMetadata.ts
    preview: {
    skeleton: (props) => ({
    variant: "grid",
    count: props.layout === "carousel" ? 4 : 8,
    }),
    },

    The editor resolves the function with the props as authored, so changing the option reshapes the placeholder without any extra work on your side.

You don't write anything else for the preview: the editor reuses the fragment from step 2. It asks the server for your section by type, and the server resolves the very fields your fragment declares — so a section never pays for another section's data, and the preview cannot drift from the published page.

note

A section without preview is treated as static: the editor never fetches anything for it. Forget it on a dynamic section and its preview stays empty, even though the published page renders fine.

The built-in ProductCard and CategoryCard sections (in @front-commerce/cms) are complete references for this pattern, including the editor preview.

Some sources need no stored key at all: user resolves the currently logged-in customer from the request. Such a section skips step 1 — it has no datasource field — and its GraphQL field reads the source directly. See the built-in CustomerOrders section.

A section can also store several keys, by putting a datasource field in the itemSchema of a repeater. The editor preview then sends one entry per row, exactly as it sends a single scalar key, and your GraphQL field resolves a list. Keep the list and its elements nullable (products: [Product]): a row whose key no longer resolves comes back as a null hole, so the list keeps the order and the arity the content manager entered instead of shrinking silently. Hide the hole from the shopper, and — if it matters to the content manager — report it in the editor, which the renderMeta.editorMode flag lets you do without touching the published page. See the built-in ProductList section.

A section can also display every entity of a collection, with nothing stored at all: its GraphQL field resolves the source's whole collection, in the order the source considers canonical (position, where it serves one), instead of one entity per key. For that, your data source implements the optional resolveCollection method — a flavor whose source does not expose it simply resolves to an empty list, and the section renders empty rather than erroring. Declare preview like any dynamic section, so the editor still resolves the field.

Those two shapes can also live in one section, the content manager choosing between them: a select prop holds the choice, and your GraphQL field resolves whichever branch the stored value asks for — the whole collection, narrowed by a level and a list of exclusions, or one entity per row of a repeater. The built-in CategoryList section is the reference: it shows the shop's navigation at the level the contributor picked, or the categories it was hand-fed, in the order they were picked. Three constraints make that shape safe:

  • Name the resolved field differently from both authored props. A prop named like the field silently takes over wherever the resolution yields nothing — a failed preview, a section past the URL budget — and your component then gets raw editor input where it expected entities.
  • Set preview.resolveWithoutKeys. The editor skips a section whose datasource props are all empty, since server-side it resolves to nothing anyway: right for a picked key, wrong for a collection, which needs no key at all. Without the flag, a section in its collection shape holds a skeleton until the content manager fills one of the pickers.
  • Declare in preview.resolveProps the props your resolver reads that are not keys — the mode, the level it narrows to. A preview request carries a section's datasource props and nothing else, which is what keeps it from refetching on every keystroke of a title. A select that decides which entities the field resolves therefore stays invisible to the server unless it is declared, and the editor then keeps showing the answer to a question it never asked.

The mode itself is an ordinary prop, so it reaches your component like any other and the component can render both shapes from it. Hide the fields that belong to the branch the mode did not choose with a condition.

On a page with many dynamic sections, the editor resolves them in batches and offers a Load more button under the preview, so opening the page doesn't fetch every section at once. A page holding more dynamic sections than one request can carry says so, and the sections it can't preview stay empty in the editor. This only affects the editor: a published page resolves all of its sections server-side.

Complete example​

The cms-section-demo example extension (under skeleton/example-extensions/) is a complete, runnable reference: it registers a Callout section, ships its Callout.CompositionMetadata.ts sibling, and renders the section component — everything described above, wired together.