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.
-
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.tsimport it outside the bundler. -
Run the three Payload commands.
Terminal window pnpm payload generate:typespnpm payload generate:importmappnpm payload migrate:creategenerate:importmapis 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
minRowsopens with that many empty rows and names@bison-lab/payload-blocks/admin#MinRowsArrayFieldas its field component, which refuses to remove a row once the count is at the minimum. Payload gates Add onmaxRowsbut never Remove onminRows.Every link a block asks for names
@bison-lab/payload-blocks/admin#LinkFieldon the row holding itstype,pageandhref. 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 ashttp,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:typesand 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: [/* ... */],} -
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 pagetype AnyBlock = NonNullable<Page["hero"]>[number] | NonNullable<Page["layout"]>[number];export const blockRegistry = {hero: SiteHeroRenderer, // yours; the package's is deliberately plainrichText: SiteRichText,showcasePanels: ShowcasePanelsBlockRenderer,processSteps: ProcessStepsBlockRenderer,faqColumns: FaqColumnsBlockRenderer,testimonialMasonry: TestimonialMasonryBlockRenderer,nap: NapBlockRenderer,} satisfies BlockRegistryFor<AnyBlock>;Add a block to the collection and
satisfiesfails typecheck until it has a renderer. Never widen that type to get past the error; add the entry. -
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";<RenderBlocksblocks={[...(page.hero ?? []), ...(page.layout ?? [])]}registry={blockRegistry}containerClassName="mx-auto w-full max-w-7xl px-6"imageComponent={NextBlockImage}linkComponent={NextBlockLink}resolveLink={resolveSiteLink}/>containerClassNameis the site’s page measure. Every renderer puts it on the band wrapper and resets the library block’s ownmax-w-*andpx-*, so the two cannot fight.imageComponent,linkComponentandresolveLinkare the three adapters: how an image is optimised, which router renders an anchor, and what path a page lives at. Read the page atdepth: 1so each link’spagerelationship is populated before it reaches a renderer.Every block renders inside an unstyled
<div data-better-editor-id={row.id}>(BLOCK_ID_ATTRIBUTEon the./reactentry). 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 anidgets 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 baresectionquery over-counts because two blocks nest a second section inside their band.
What a renderer is handed
Section titled “What a renderer is handed”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 ownresolveLink; 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
hrefis never rewritten. - A row saved before links had a
typecarries only anhref, 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 totype: externalonly 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.
The seam
Section titled “The seam”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";Live preview and the samples
Section titled “Live preview and the samples”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.