Page Designer in Storefront Next
In Storefront Next your React components are the Page Designer components. You annotate them with decorators, the build generates Business Manager metadata (JSON) from those decorators, and at runtime a route loader fetches the page structure from the Shopper Experience API and <Region> renders it.
The in-project guide is docs/README-PAGE-DESIGNER.md and the rules in AGENTS.md apply; this skill is the task-oriented path through them. Where the guide and the source disagree, trust the source.
Pieces and where they live
| Piece | Location in your project |
|-------|--------------------------|
| Decorators (Component, AttributeDefinition, RegionDefinition, PageType) | @/lib/decorators (src/lib/decorators/) |
| Page fetch with per-component loader promises | fetchPageWithComponentData in @/lib/page-designer/page-loader.server |
| Single-component fetch (preview, embedded) | fetchComponentWithComponentData in @/lib/page-designer/component-loader.server |
| Region renderer | Region from @/components/region |
| Generated registry (do not hand-edit) | src/lib/page-designer/static-registry.ts |
| Runtime hooks (usePageDesignerMode) | @salesforce/storefront-next-runtime/design/react/core |
| Design/preview mode detection in loaders | isDesignModeActive / isPreviewModeActive from @salesforce/storefront-next-runtime/design/mode |
| Generated Business Manager metadata | cartridges/app_storefrontnext_base/cartridge/experience/{components,pages,aspects} |
The runtime package's /design entry exports only the registry. Decorators come from your project, not from @salesforce/storefront-next-runtime.
Workflow
- Create or edit the component (metadata class, props, default export,
fallback, optionalloader) undersrc/components/<name>/index.tsx. - Run
pnpm dev(orpnpm build). The Vite plugin scanssrc/componentsfor@Componentand rewrites the registry between theSTATIC_REGISTRY_START/ENDmarkers. pnpm cartridge:generatewrites the metadata JSON (pnpm buildruns it for you).pnpm cartridge:validatechecks the generated JSON against the schemas. Generation also fails on an invalid attribute config (for example a badsearchingcombination).pnpm cartridge:deployuploads the cartridge to the B2C instance (pnpm cartridge:deploy -- --deleteremoves old cartridge files first). The optional MCP toolcartridge_deploydoes the same. Seeb2c-cli:b2c-codefor credentials and code-version handling.- In Business Manager, merchants build pages from the new palette entries. See Business Manager.
A component
// src/components/promo-banner/index.tsx
import { AttributeDefinition, Component, RegionDefinition } from '@/lib/decorators';
import { DynamicImage } from '@/components/dynamic-image';
import { type Image } from '@/types';
import { cn } from '@/lib/utils';
@Component('promoBanner', {
name: 'Promo Banner',
description: 'Headline and optional image. Headline text and alignment are editable.',
group: 'Content',
})
@RegionDefinition([])
export class PromoBannerMetadata {
@AttributeDefinition({ id: 'headline', name: 'Headline', type: 'string', required: true, defaultValue: 'Spring sale' })
headline?: string;
@AttributeDefinition({ id: 'image', name: 'Image', type: 'image' })
image?: Image;
@AttributeDefinition({ id: 'align', name: 'Alignment', type: 'enum', values: ['left', 'center'], defaultValue: 'left' })
align?: 'left' | 'center';
}
interface PromoBannerProps {
headline?: string;
image?: Image;
align?: 'left' | 'center';
className?: string;
}
// Page Designer also injects component, data, designMetadata and regionId.
// Never spread them onto a DOM element; destructure them out first if you forward ...rest.
export default function PromoBanner({ headline = 'Spring sale', image, align = 'left', className }: PromoBannerProps) {
return (
<section className={cn('relative', align === 'center' && 'text-center', className)}>
{image?.url && <DynamicImage src={image.url} alt="" />}
<h2>{headline}</h2>
</section>
);
}
// REQUIRED. Rendered in a Suspense boundary; receives the same attribute props. Keep it light.
export function fallback() {
return <div className="h-48 animate-pulse bg-muted" />;
}
Rules that matter:
- Attribute values arrive as props named after the class field, so the field name and
idmust agree. - The metadata class must be
exported; the generator only sees exported classes. - The
typeIdmust be a string literal. It is stored as<group>.<typeId>(default groupstorefrontnext_base), for exampleContent.promoBanner. Use thegroupoption (Content,Layout, ...) so related components sit together in the palette. - An
imageattribute delivers an object ({ url, focalPoint?, metaData? }), typedImagefrom@/types, not a string. Checksrc/components/hero/index.tsxfor the pattern. nameanddescriptionare what merchants read in Business Manager; omitted ones fall back to the raw id.- Full option tables, attribute types, enums, nested regions and cross-group refs: Decorator Patterns.
A loader
Export a callable named loader when the component needs data (products, categories, your own API). It receives { componentData, context, request }; componentData is the whole SCAPI component object, so merchant-set attributes live at componentData.data.
// src/components/promo-products/loaders.ts
import type { LoaderFunctionArgs } from 'react-router';
import type { ShopperExperience } from '@/scapi';
import { fetchProductsByIds } from '@/lib/api/products.server';
export const loader = async (args: { componentData: unknown; context: LoaderFunctionArgs['context'] }) => {
const comp = args.componentData as ShopperExperience.schemas['Component'];
const { productIds } = (comp.data ?? {}) as { productIds?: string };
if (!productIds) return null;
return fetchProductsByIds(args.context, productIds.split(','));
};
// src/components/promo-products/index.tsx
export { loader } from './loaders';
export function fallback() { /* skeleton with reserved height */ }
export default function PromoProducts({ data }: { data?: Product[] | null }) { /* ... */ }
- The exported
loadermust be a function. An object such as{ server: fn }is silently ignored anddatastays undefined; unwrap it (export const loader = loaders.server), asproduct-tile/index.tsxdoes. - The loader runs on the server only (stripped from the client bundle), so it may import
*.servermodules; the component file must not. An optionalclientLoaderexport is client-only. - Return
nullwhen nothing is configured, fetch in parallel withPromise.all, and let errors propagate so only that component is hidden. - The registry records
{ loader: 'loader' }and{ fallback: 'fallback' }capability flags for you on regeneration.
More on the registry shape, group-qualified ids, preload manifest and entry wiring: Registry and Loading.
A page route
Routes bind a URL to a Page Designer page template and render its regions. Routes with @PageType today: home (_app._index.tsx), PLP (_app.c.$.tsx), PDP (_app.p.$.tsx), search (_app.search.tsx), about-us (_app.about-us.tsx), and the component preview route. Cart, checkout, account and auth do not use Page Designer.
import { Region } from '@/components/region';
import { PageType } from '@/lib/decorators/page-type';
import { RegionDefinition } from '@/lib/decorators/region-definition';
import { fetchPageWithComponentData } from '@/lib/page-designer/page-loader.server';
@PageType({
name: 'Landing Page',
description: 'Campaign landing page with a banner and a main content area',
supportedAspectTypes: [],
})
@RegionDefinition([
{ id: 'banner', name: 'Banner Region', maxComponents: 1 },
{ id: 'main', name: 'Main Region' },
])
export class LandingPageMetadata {}
export function loader(args: Route.LoaderArgs) {
return { page: fetchPageWithComponentData(args, { pageId: 'landing' }) };
}
export default function Landing({ loaderData }: Route.ComponentProps) {
return (
<>
<Region page={loaderData.page} regionId="banner" />
<Region page={loaderData.page} regionId="main" />
</>
);
}
- Fetch by
{ pageId }for a fixed page, or by aspect:{ aspectType: 'pdp', productId, categoryId? }and{ aspectType: 'plp', categoryId }. TheaspectTypepassed to the fetch must agree with@PageType.supportedAspectTypes(['pdp'],['plp'], or[]for fixed-page routes such as home); a mismatch shows the wrong template in Business Manager with no error. fetchPageWithComponentDataattaches the per-component loader promises to the page (page.componentData).<Region>reads them itself; it takes nocomponentDataprop, and the loader returns just{ page }. It resolves tonullwhen the page is missing (404) or SCAPI errors, so empty regions are the normal unconfigured state.<Region page={...}>accepts a promise and renders in Suspense. AddfallbackElementonly for a visible skeleton.- Add
criticalto a page-level region only for above-the-fold or LCP content, and only with an awaited page (page: await fetchPageWithComponentData(...)), as_app._index.tsxdoes. Never on below-the-fold or catch-all regions. Details in Registry and Loading. - Nested regions inside a component use component mode, synchronously:
<Region component={component} regionId="content" />. No Suspense wrapper, nofallbackElement, no promises. Seesrc/components/grid/index.tsx. - Do not use
errorElementto render hard-coded content for an unconfigured page: it defeats merchant control and forces loaders to fetch data only for the fallback. The home route still contains such anerrorElement; do not copy it. UsefallbackElementfor loading and render nothing for empty. - Metadata classes on routes are empty, exported, and never carry
@AttributeDefinition.
Design and preview mode
Business Manager loads your storefront in an iframe. In loaders use isDesignModeActive(request) / isPreviewModeActive(request) (the page loader already does and switches to the pageId/pdToken passed by Business Manager). In components use usePageDesignerMode() from @salesforce/storefront-next-runtime/design/react/core. PageDesignerInit (src/page-designer-init.tsx, rendered by root.tsx) blocks link navigation while editing and loads design-mode styles; do not remove it.
Verify before you finish
- [ ] Metadata class exported;
typeIdliteral;groupset; every attribute hasname,description, correcttype - [ ] Default export is the component;
fallbackexported and lightweight;loader(if any) is a function - [ ] Every
<Region regionId>matches a@RegionDefinitionid, and vice versa - [ ]
pnpm cartridge:generate && pnpm cartridge:validatepass; the component is instatic-registry.ts - [ ] Review against Review Checklist; symptoms in Troubleshooting
Reference Documentation
- Decorator Patterns - options, attribute types, regions, page types
- Registry and Loading - static registry, loaders, critical regions, entry wiring
- Business Manager -
routeandaspectTypeIds, page setup, palette - Review Checklist - what to check in a component or page route
- Troubleshooting - component missing, empty, or wrong data
Related Skills
storefront-next:sfnext-components- component conventions, shadcn primitives, Storybookstorefront-next:sfnext-data-fetching- loaders,createApiClients, streaming with Suspense/Awaitstorefront-next:sfnext-scapi- calling SCAPI from loaders and actionsstorefront-next:sfnext-performance- LCP, preload, critical datastorefront-next:sfnext-theming- tokens and brand styling for new componentsstorefront-next:sfnext-testing- component and story testsstorefront-next:sfnext-deployment- shipping the storefront bundlefigma-to-sfnext-pagedesigner:figma-to-sfnext-pagedesigner- Figma frame to Page Designer blocksb2c:b2c-page-designer- classic (ISML/SFRA) Page Designerb2c-cli:b2c-code- deploying cartridges and code versions