Skip to content

Wire a site

The package ships configs and renderers. Four things stay in the site, on purpose: the collection the blocks go in, the generated types, the registry that maps slugs to renderers, and the page that calls RenderBlocks.

Theme is required. Register createTheme and createBrandAssets, seed the Theme row, and render themeHead from the root layout. There is no option to leave the Theme Global off. See Theme.

  1. Add the blocks to a collection.

    collections/Pages.ts
    import type { CollectionConfig } from "payload";
    import {
    FaqColumnsBlock,
    HeroBlock,
    NapBlock,
    ProcessStepsBlock,
    ResourcesBlock,
    RichTextBlock,
    ShowcasePanelsBlock,
    StatsBandBlock,
    TestimonialMasonryBlock,
    VideoBlock,
    } from "@bison-lab/payload-blocks";
    export const Pages: CollectionConfig = {
    slug: "pages",
    fields: [
    { name: "hero", type: "blocks", blocks: [HeroBlock], maxRows: 1 },
    {
    name: "layout",
    type: "blocks",
    blocks: [
    RichTextBlock,
    ShowcasePanelsBlock,
    ProcessStepsBlock,
    StatsBandBlock,
    VideoBlock,
    FaqColumnsBlock,
    TestimonialMasonryBlock,
    ResourcesBlock,
    NapBlock,
    ],
    },
    ],
    };

    The main entry is plain Node with no React in it, which is what lets payload.config.ts import it outside the bundler.

  2. Run the three Payload commands.

    Terminal window
    pnpm payload generate:types
    pnpm payload generate:importmap
    pnpm payload migrate:create

    generate:importmap is what wires the admin fields, and the step is not optional: for a field whose custom component is missing from the import map, Payload logs the miss and renders the field as nothing, not the stock field, so a forgotten step leaves every link row blank in the admin. The payload-blocks map includes MinRowsArrayField, ItemsField, LinkField, NavItemsField and AdminWorkspace. Theme, Roles, Features, Admin nav and LookField come from @bison-lab/payload-core/admin.

    Every array in the package with a minRows opens with that many empty rows and names @bison-lab/payload-blocks/admin#MinRowsArrayField as its field component, which refuses to remove a row once the count is at the minimum. The stats band’s stats and the showcase panels use ItemsField instead (below), which refuses the same. Payload gates Add on maxRows but never Remove on minRows.

    Every link a block asks for names @bison-lab/payload-blocks/admin#LinkField on the row holding its type, page and href. The picker replaces those three inputs with one box: type to search the pages collection by title (published pages only, filtered by the server on every keystroke, each result shown with its path), or paste anything that can only be a destination, such as http, mailto:, tel: or a site path starting with /. The chosen state is a Page or External chip with a clear button. A page that was unpublished or deleted after it was picked shows a warning there, and Payload refuses to publish the document until it is picked again or republished. The picker writes the same three fields the stock inputs would, so the REST API, generate:types and a site that reaches the row without the picker all see one shape.

    Your own arrays can use the same field:

    import { MIN_ROWS_ARRAY_FIELD, emptyRows } from "@bison-lab/payload-blocks";
    {
    name: "links",
    type: "array",
    minRows: 2,
    defaultValue: emptyRows(2),
    admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
    fields: [/* ... */],
    }

    A block’s repeating items (cards, checklist items, testimonials) can use the admin kit’s items list instead: ITEMS_FIELD, through itemsFieldAdmin(). Rows open one at a time, and the ⋯ menu moves and deletes them. Each row’s sub-fields are still Payload’s own components, so the stored shape does not change, and a row below minRows still cannot be removed. layout: "grid" draws portrait tiles. title, thumbnail and meta may name a field inside a group (author.name), meta may be a list read until one is not empty, numbered: "Step" shows each row’s position, initials: true puts the title’s initials in an empty thumbnail, and link: true (or the name of the group holding the link) shows where a call to action goes: the picked page’s title, or the URL. pair lets a row move to a sibling array, and a row that is one text field takes a pasted list. The list sits the same distance from the fields around it as they do from each other.

    import { emptyRows, itemsFieldAdmin } from "@bison-lab/payload-blocks";
    {
    name: "cards",
    type: "array",
    minRows: 3,
    defaultValue: emptyRows(3),
    admin: itemsFieldAdmin({ title: "title", thumbnail: "image", meta: "stat" }),
    fields: [/* ... */],
    }
  3. Own the registry.

    The registry is a parameter, not an export, so the compile-time layout lock lives where the generated types are:

    blocks/registry.ts
    import type { Page } from "@/payload-types";
    import {
    type BlockRegistryFor,
    FaqColumnsBlockRenderer,
    FeatureChecklistBlockRenderer,
    FeatureHighlightsBlockRenderer,
    HeroBlockRenderer,
    HeroCompactBlockRenderer,
    HeroSimpleBlockRenderer,
    HeroSweepBlockRenderer,
    NapBlockRenderer,
    ProcessStepsBlockRenderer,
    ResourcesBlockRenderer,
    ShowcasePanelsBlockRenderer,
    StatsBandBlockRenderer,
    TestimonialCarouselBlockRenderer,
    TestimonialMasonryBlockRenderer,
    VideoBlockRenderer,
    } from "@bison-lab/payload-blocks/react";
    import { SiteRichText } from "@/blocks/rich-text"; // richTextBlockRenderer({ resolveLink }), see the Rich text page
    type AnyBlock = NonNullable<Page["hero"]>[number] | NonNullable<Page["layout"]>[number];
    export const blockRegistry = {
    hero: HeroBlockRenderer, // sweep, on the `hero` slug for this release
    heroSweep: HeroSweepBlockRenderer,
    heroCompact: HeroCompactBlockRenderer,
    heroSimple: HeroSimpleBlockRenderer,
    richText: SiteRichText,
    showcasePanels: ShowcasePanelsBlockRenderer,
    featureHighlights: FeatureHighlightsBlockRenderer,
    featureChecklist: FeatureChecklistBlockRenderer,
    processSteps: ProcessStepsBlockRenderer,
    statsBand: StatsBandBlockRenderer,
    video: VideoBlockRenderer,
    faqColumns: FaqColumnsBlockRenderer,
    testimonialMasonry: TestimonialMasonryBlockRenderer,
    testimonialCarousel: TestimonialCarouselBlockRenderer,
    resources: ResourcesBlockRenderer,
    nap: NapBlockRenderer,
    } satisfies BlockRegistryFor<AnyBlock>;

    Add a block to the collection and satisfies fails typecheck until it has a renderer. Never widen that type to get past the error; add the entry.

  4. Render the page.

    app/[...slug]/page.tsx
    import { RenderBlocks } from "@bison-lab/payload-blocks/react";
    import { getSectionControls } from "@bison-lab/payload-core/block-library";
    import { blockRegistry } from "@/blocks/registry";
    import { NextBlockImage, NextBlockLink, resolveSiteLink } from "@/blocks/adapters";
    const sectionControls = await getSectionControls(payload);
    <RenderBlocks
    blocks={[...(page.hero ?? []), ...(page.layout ?? [])]}
    registry={blockRegistry}
    containerClassName="mx-auto w-full max-w-7xl px-6"
    imageComponent={NextBlockImage}
    linkComponent={NextBlockLink}
    resolveLink={resolveSiteLink}
    sectionControls={sectionControls}
    />

    sectionControls is the Block library’s answer to which Block Settings each block offers. A stored value for an unticked control is ignored at render; leave the prop off and every control applies.

    containerClassName is the site’s page measure. Every renderer puts it on the band wrapper and resets the library block’s own max-w-* and px-*, so the two cannot fight. imageComponent, linkComponent and resolveLink are the three adapters: how an image is optimised, which router renders an anchor, and what path a page lives at. Read the page at depth: 1 so each link’s page relationship is populated before it reaches a renderer.

    Every block renders inside an unstyled <div data-better-editor-id={row.id}> (BLOCK_ID_ATTRIBUTE on the ./react entry). That is the hook payload-better-editor needs to turn a click in its preview iframe into the clicked row’s fields, so a site adopting that editor has nothing to add per block; a row without an id gets no attribute. It also means each band sits one level below the render root: a stylesheet or a test that reaches the root’s direct children meets the wrapper, not the band, and a bare section query over-counts because six blocks (faqColumns, testimonialMasonry, testimonialCarousel, featureHighlights, featureChecklist and resources) nest a second section inside their band.

RenderBlocks gives each renderer its row plus its neighbours: index on the page, prevType (absent on the first block), runIndex within a run of same-type blocks, and isLast. A block uses them to close a seam against the block above, alternate surfaces across back-to-back blocks, or sit correctly against the footer.

A row saved against a block that has since left the config has no renderer. It is dropped before anything is counted, so its neighbours see each other, not a gap. In development RenderBlocks warns once per unknown type; onUnknownBlock fires for every dropped row in every environment. orphanBlockTypes(rows, registry) from the main entry (React-free) lists those types so a site test can fail when an upgrade would strand content.

A row whose upload has not resolved is dropped rather than rendered empty, and a block left with nothing to show renders nothing. Draft saves skip Payload validation, so live preview genuinely hands renderers incomplete rows.

Every link a block asks for is stored as three fields: type (page or external), page (a relationship into the site’s pages collection, published rows only) and href. Renderers never read href directly. They hand the link to resolveLink, and the result is what goes to linkComponent.

  • A published, populated page resolves to /<slug> by default. A site whose routes differ passes its own resolveLink; see Page paths.
  • A page that is missing or unpublished resolves to null, and the renderer shows the label as text rather than an anchor that points nowhere. Payload hands the public read the bare id when the reader may not see the page, which is what unpublished looks like from the site. A menu drops such a link instead: an item that goes nowhere is worse than one fewer item.
  • An external link’s href is never rewritten.
  • A row saved before links had a type carries only an href, and every reader takes that as an external link: the site keeps rendering it and the admin shows it as an External chip. A site’s migration to type: external only makes the stored row say so.

No destination is a typed path any more. A showcase panel’s “read more” destination, a stat’s link in the stats band and the mega-menu trigger landing page are all linkDestinationFields() and go through resolveLink. The panel and the stat kept the href name, so one saved when it was a typed path is read as an external link and keeps rendering; run payload migrate:create for the new type, page and newTab columns.

One block floats across the join between two bands: the stats band with overlap on. It pulls itself up over the block above and down over the block below with negative margins, and each neighbour adds the same distance on its own side, so the band sits across the seam without covering copy. Nothing is configured for this. RenderBlocks works out which rows float and hands every renderer two more neighbour facts, overlapAbove and overlapBelow. A package renderer’s band wrapper adds seamClasses(props), and a renderer never reads a neighbour’s row. The heroes apply that same distance inside the padding they already own.

overlapBelow adds the hero’s own bottom clearance (sweep pb-36 / lg:pb-48, compact pb-28 / lg:pb-36) so a floating band can sit across the seam. Their header clearance is already taller than the pull from above, so overlapAbove adds nothing. A site that still paints the hero passes the same flag through:

export function SiteHeroRenderer(props: BlockRendererProps<HeroBlockData>) {
return <HeroSweep overlap={props.overlapBelow} sectionClassName={props.sectionClassName} />;
}

Which rows float is the floats prop of RenderBlocks, defaulting to overlapsSeam. A site with a floating block of its own composes it:

<RenderBlocks
{...props}
floats={(row) => overlapsSeam(row) || row.blockType === "bookingCard"}
/>

SEAM_PULL_UP and SEAM_PULL_DOWN are the floating block’s negative margins, and OVERLAP_ABOVE_CLEARANCE and OVERLAP_BELOW_CLEARANCE the matching padding, all from the ./react entry, so a site block that floats uses the distance its neighbours clear.

Tailwind has to see the renderers. Those margins and paddings are utilities in this package’s output, not in @bison-lab/ui, so the site’s stylesheet scans both packages:

@source "../../../node_modules/@bison-lab/ui/dist";
@source "../../../node_modules/@bison-lab/payload-blocks/dist";

Every block ships a sample row, so a Block library page or a live preview can render it with no CMS behind it. See Samples.