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.

  1. Add the blocks to a collection.

    collections/Pages.ts
    import type { CollectionConfig } from "payload";
    import {
    FaqColumnsBlock,
    HeroBlock,
    NapBlock,
    ProcessStepsBlock,
    RichTextBlock,
    ShowcasePanelsBlock,
    TestimonialMasonryBlock,
    } 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,
    FaqColumnsBlock,
    TestimonialMasonryBlock,
    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 two 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.

    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. 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: [/* ... */],
    }
  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,
    NapBlockRenderer,
    ProcessStepsBlockRenderer,
    ShowcasePanelsBlockRenderer,
    TestimonialMasonryBlockRenderer,
    } 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: SiteHeroRenderer, // yours; the package's is deliberately plain
    richText: SiteRichText,
    showcasePanels: ShowcasePanelsBlockRenderer,
    processSteps: ProcessStepsBlockRenderer,
    faqColumns: FaqColumnsBlockRenderer,
    testimonialMasonry: TestimonialMasonryBlockRenderer,
    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 { blockRegistry } from "@/blocks/registry";
    import { NextBlockImage, NextBlockLink, resolveSiteLink } from "@/blocks/adapters";
    <RenderBlocks
    blocks={[...(page.hero ?? []), ...(page.layout ?? [])]}
    registry={blockRegistry}
    containerClassName="mx-auto w-full max-w-7xl px-6"
    imageComponent={NextBlockImage}
    linkComponent={NextBlockLink}
    resolveLink={resolveSiteLink}
    />

    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 two blocks 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.

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.

Three destinations are still typed paths rather than picked pages: a mega menu trigger’s landing page, a showcase panel’s “read more” destination, and a stat’s link in the stats band. Their renderers read the href as written.

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; every package renderer’s band wrapper adds seamClasses(props), and a renderer never reads a neighbour’s row.

A site’s own renderer does the same. The usual case is the hero, which keeps symmetric padding until an editor drops an overlapping band under it:

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

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.