Skip to main content
Version: next

Content Composition

Since version 3.11

The Content Composition API allows you to create and compose custom building blocks for your content.

References

API

register lifecycle hook

A method that allows you to register a new composition or extend an existing one.

Parameters

  • name: string - The name of the composition
  • collection:CompositionCollection - An array of composition entries.

Example

./example-extension/acme-extension/index.ts
export default defineExtension({
unstable_lifecycleHooks: {
onContentCompositionInit: (composition) => {
composition.register("Wysiwyg", [
{
// Since this exists, it will override the existing composition
name: "DefaultWysiwyg",
client: {
component: new URL(
"./components/DefaultWysiwyg.tsx",
import.meta.url
),
},
fragment: /* GraphQL */ `
fragment DefaultWysiwygFragment on DefaultWysiwyg {
childNodes
}
`,
},
{
// This will be added to the composition for `Wysiwyg`
name: "CustomWysiwyg",
client: {
component: new URL(
"./components/MagentoWysiwyg.tsx",
import.meta.url
),
},
fragment: /* GraphQL */ `
fragment CustomWysiwygFragment on CustomWysiwyg {
childNodes
}
`,
},
]);
},
},
});

createContentComposition helper

This method is a helper to create a Content Composition collection, which can then be used in the onContentCompositionInit lifecycle hook to register your composition.

Parameters

See register for more information.

Example

./example-extension/acme-extension/index.ts
import { createContentComposition } from "@front-commerce/core";

const WysiwygComposition = createContentComposition("Wysiwyg", [
{
// Since this exists, it will override the existing composition
name: "DefaultWysiwyg",
client: {
component: new URL("./components/DefaultWysiwyg.tsx", import.meta.url),
},
fragment: /* GraphQL */ `
fragment DefaultWysiwygFragment on DefaultWysiwyg {
childNodes
}
`,
},
{
// This will be added to the composition for `Wysiwyg`
name: "CustomWysiwyg",
client: {
component: new URL("./components/MagentoWysiwyg.tsx", import.meta.url),
},
fragment: /* GraphQL */ `
fragment CustomWysiwygFragment on CustomWysiwyg {
childNodes
}
`,
},
]);

export default defineExtension({
unstable_lifecycleHooks: {
onContentCompositionInit: (composition) => {
composition.registerComposition(WysiwygComposition);
},
},
});

Component metadata

In addition to runtime components, each registered entry can optionally expose typed metadata — editor hints, feature flags, SEO information, documentation, anything a consumer of the composition wants to attach. The metadata channel is separate from the components channel: it lives in its own virtual module and only enters a bundle when explicitly imported.

Authoring

Metadata is declared in a sibling file named <ComponentName>.CompositionMetadata.ts, colocated with the component:

sections/HeroBanner/
├── HeroBanner.tsx
└── HeroBanner.CompositionMetadata.ts ← sibling metadata file

The sibling exposes a named export whose name matches the scope: <Scope>CompositionMetadata. The plugin reads this convention to wire each entry in the virtual metadata module.

./sections/HeroBanner/HeroBanner.CompositionMetadata.ts
import type { SectionMetadata } from "@my-package/types";

export const SectionCompositionMetadata = {
displayName: "Hero banner",
category: "marketing",
// …whatever the package's declared shape contains
} satisfies SectionMetadata;

Filename convention

The plugin replaces the rightmost .ts or .tsx suffix of the component file with .CompositionMetadata.ts. Casing is verbatim — case-sensitive filesystems (Linux / CI) will not silently match compositionMetadata or similar variants. macOS authors may get false positives on a case-insensitive filesystem that break on CI; match the canonical casing exactly.

Typing — two-step adoption

The typing model is per-scope: the package that owns a composition scope declares the canonical metadata shape for that scope once, and every sibling under that scope conforms to it.

Step 1 — own a scope

Augment @front-commerce/types#CompositionMetadataMap in a .d.ts shipped by the scope-owning package. The file must contain at least one top-level import or export statement so TypeScript treats it as a module — otherwise the declare module block silently shadows the package and breaks consumers elsewhere:

@my-package/src/types/composition-metadata.d.ts
import type { SectionMetadata } from "./SectionMetadata";

export {};

declare module "@front-commerce/types" {
interface CompositionMetadataMap {
Section: SectionMetadata;
}
}

Step 2 — author siblings

Each component's <Component>.CompositionMetadata.ts exports a constant named <Scope>CompositionMetadata that satisfies the augmented shape (see the authoring example above). The plugin discovers the entry keys at build time and augments CompositionMetadataKeys automatically by emitting .front-commerce/content-composition-metadata.d.ts during vite build / vite dev.

Reading metadata at runtime

Three consumer APIs:

  • useCompositionComponentMetadata — React hook returning the whole entry map for a scope. Use it when you need to iterate over every component registered in the scope.
  • useCompositionComponentMetadataForComponent — React hook returning a single entry's metadata, narrowed to the scope's declared shape (or null when the component has no sibling metadata file).
  • getCompositionMetadataCollection — non-React helper returning the entry map for a scope. Importable from server loaders and utilities.
import {
useCompositionComponentMetadata,
useCompositionComponentMetadataForComponent,
} from "@front-commerce/core/react";

function HeroBannerLabel() {
const meta = useCompositionComponentMetadataForComponent(
"Section",
"HeroBanner"
);
// meta: SectionMetadata | null
return <span>{meta?.displayName ?? "(unlabelled)"}</span>;
}

function SectionGallery() {
const all = useCompositionComponentMetadata("Section");
// all: { HeroBanner: SectionMetadata | null; … }
return (
<ul>
{Object.entries(all).map(([name, meta]) => (
<li key={name}>{meta?.displayName ?? name}</li>
))}
</ul>
);
}
import { getCompositionMetadataCollection } from "@front-commerce/core/content-composition";

const all = getCompositionMetadataCollection("Section");
// all: { HeroBanner: SectionMetadata | null; … }

Calling either API with a scope that the type system does not know (i.e. not augmented in CompositionMetadataMap) is a compile-time error at the call site.

Scope ownership rule

One package owns each scope's metadata shape. If you need a different metadata shape, register a different scope name — do not augment an existing scope owned by another package. TypeScript merges interface declarations; conflicting field types produce Subsequent property declarations must have the same type errors at the consumption site, far from the offending augmentation.

The package that first calls composition.register("<scope>", ...) is the conventional owner. Downstream extensions adding components to the same scope must conform to the owner's shape via the same <Scope>CompositionMetadata named export.