@bison-lab/payload-blocks
v4.8.0
Published
Payload CMS block configs and renderers for the Bison Lab marketing blocks
Maintainers
Readme
@bison-lab/payload-blocks
Payload CMS block configs and renderers for the Bison Lab marketing blocks.
Full guide, with every block rendered live: https://payload.bisonlab.ai/
Install it in a Payload site and editors get the same sections @bison-lab/ui
ships as React props — showcase panels, process steps, FAQ columns, testimonial
masonry, NAP — as blocks they can add to a page.
pnpm add @bison-lab/payload-blocksPeers: payload (required); react, react-dom, @bison-lab/ui and
@payloadcms/richtext-lexical (needed by the renderer entries, optional if you
only use the configs); @payloadcms/ui (needed by the admin entry, which every
Payload site already has).
Four entry points, and why
| Import | Contents | Runs where |
| --- | --- | --- |
| @bison-lab/payload-blocks | Block configs, field builders, row types, resolveMedia | Node. This is what payload.config.ts imports, and it touches no React. |
| @bison-lab/payload-blocks/react | Renderers, RenderBlocks, the image and link seams, the rendering types | Client ("use client"). |
| @bison-lab/payload-blocks/rich-text | The richText renderer, internalDocToHrefFrom, and richTextBlockRenderer() to build one that resolves internal links | Client. Split out because it is the only thing that needs @payloadcms/richtext-lexical. |
| @bison-lab/payload-blocks/admin | MinRowsArrayField, LinkField, NavItemsField, AdminWorkspace, and FooterColumnsField — the admin fields the configs reference by path | Client, inside the Payload admin. Resolved through the site's import map, never imported by hand. |
The renderers are client components because the blocks they render are: every
@bison-lab/ui export is a client reference, and these blocks are interactive
anyway. That is also what makes imageComponent work — a Server Component can
hand a client component reference across the boundary, but not a closure.
Wiring a site
1. Add the blocks to a collection.
import {
FaqColumnsBlock,
HeroBlock,
HeroCompactBlock,
HeroSweepBlock,
NapBlock,
ProcessStepsBlock,
RichTextBlock,
ShowcasePanelsBlock,
TestimonialMasonryBlock,
} from '@bison-lab/payload-blocks'
export const Pages: CollectionConfig = {
slug: 'pages',
fields: [
{ name: 'hero', type: 'blocks', blocks: [HeroBlock, HeroSweepBlock, HeroCompactBlock], maxRows: 1 },
{
name: 'layout',
type: 'blocks',
blocks: [
RichTextBlock,
ShowcasePanelsBlock,
ProcessStepsBlock,
FaqColumnsBlock,
TestimonialMasonryBlock,
NapBlock,
],
},
],
}Then payload generate:types, payload generate:importmap and
payload migrate:create.
The import map step is what wires the admin fields, and it 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 (an empty element stands in for the component; the stock field does not come back). Two fields need it.
Every array in this 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 down to the
minimum (Payload gates Add on maxRows but never Remove on minRows). A site
can put the same field on its own arrays:
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: [...],
}Every link a block asks for names @bison-lab/payload-blocks/admin#LinkField
on the row that holds its type, page and href, and the picker replaces
those three inputs with one box: type to search the pages collection by its
title field (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 — http, mailto:, tel:, or a site path starting with / —
and the one offer is to link to it. 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, since the relationship only accepts
published rows. 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: page shows for type: page, href
for type: external, and required binds whichever is active. A site's own
block takes the same field through linkFields() or linkField(), and can
point it at another collection with pagesCollection.
lookField() and colorTokenField() read the published Theme. Options are
the scales Color Settings offers page editors (Primary, Secondary, Accent,
Highlight, plus every custom color) and only the included steps. Success and
Destructive stay off that list. Adding Coral on Colors makes coral /
coral-400 appear with no package bump. The picker is
@bison-lab/payload-core/admin#LookField — run payload generate:importmap.
Pass the Theme document into themeHeadFromDoc so those keys paint
([data-look] + --coral-*).
import { lookField, colorTokenField } from '@bison-lab/payload-blocks'
lookField()
colorTokenField({ name: 'fill' })2. Own the registry.
The registry is a parameter, not an export, so the compile-time layout lock stays in your site — where the generated types are:
import type { Page } from '@/payload-types'
import {
type BlockRegistryFor,
FaqColumnsBlockRenderer,
HeroBlockRenderer,
HeroCompactBlockRenderer,
HeroSweepBlockRenderer,
NapBlockRenderer,
ProcessStepsBlockRenderer,
ShowcasePanelsBlockRenderer,
TestimonialMasonryBlockRenderer,
} from '@bison-lab/payload-blocks/react'
import { RichTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'
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,
richText: RichTextBlockRenderer,
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.
3. Render the page.
<RenderBlocks
blocks={[...page.hero, ...page.layout]}
registry={blockRegistry}
containerClassName={CONTAINER}
imageComponent={NextBlockImage}
linkComponent={NextBlockLink}
/>containerClassName is your site's page measure. Every renderer puts it on the
band wrapper and resets the library block's own max-w-* / px-* so the two
cannot fight.
Every block renders inside an unstyled <div data-better-editor-id={row.id}>
(BLOCK_ID_ATTRIBUTE). 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 test or a stylesheet that reaches the root's direct children (container.children,
:scope > section) meets the wrapper, not the band.
imageComponent is how images get optimised. The package has no next
dependency — next/image needs per-site configuration this package cannot
supply — so a Next site writes one adapter and passes it once:
'use client'
import Image from 'next/image'
import type { BlockImageProps } from '@bison-lab/payload-blocks/react'
export function NextBlockImage({ src, alt, width, height, ...rest }: BlockImageProps) {
if (!width || !height) return <Image src={src} alt={alt} fill {...rest} />
return <Image src={src} alt={alt} width={width} height={height} {...rest} />
}Uploads are served from /api/media/file/…, so widen next.config.ts
images.localPatterns (or remotePatterns) to match before any CMS image
renders.
linkComponent is the same seam for links. No renderer writes an <a>
itself (one rich-text node excepted, below): every href a block emits — a hero call to action, a showcase panel's
corner action, a NAP tel: link, a menu, a link typed into a richText
section — goes through the component you pass, so a Next site's navigation
stays client-side. newTab arrives as a flag
and as target/rel already expanded from it, so the adapter only has to
drop the flag before spreading:
'use client'
import Link from 'next/link'
import type { BlockLinkComponent } from '@bison-lab/payload-blocks/react'
export const NextBlockLink: BlockLinkComponent = ({ newTab, ...props }) => <Link {...props} />Without one, DefaultBlockLink renders a plain anchor and sets target and
rel together whenever newTab is on.
resolveLink is how a page link becomes a path. Every link a block asks for
(linkFields(): a hero call to action, the testimonial link, the FAQ call to
action, a menu link) is stored as type (page or external), page (a
relationship into the site's pages collection, published rows only) and
href. A depth: 1 page read populates page, and the renderer hands the
link to resolveLink for its href. The default makes it /<slug>; a site
whose routes differ passes its own, exported from a client module like the
adapters above (a Server Component cannot hand a closure across the boundary):
'use client'
import { resolveLink, type ResolveLink } from '@bison-lab/payload-blocks/react'
import { pagePath } from '@/lib/routes' // your site's route helper
export const resolveSiteLink: ResolveLink = (link) =>
link.type === 'page' && typeof link.page === 'object' && link.page?.slug
? pagePath(link.page.slug)
: resolveLink(link)A page link whose page 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 (resolveLink, the stock
fields' conditions, the picker) 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.
Links in a richText section go through linkComponent too, the ones Lexical
auto-detects included. One kind needs more: a link an editor makes to another
document has no URL, only the document, and the route is the site's to know.
That is the same knowledge resolveLink carries, but Lexical hands the
renderer a link node rather than one of the link rows above. The helper
internalDocToHrefFrom unpacks that node into a page destination and calls
the site's resolveLink, so the page path is defined once. Pass that same
function to the factory (richTextBlockRenderer({ resolveLink })) and
register the result in place of RichTextBlockRenderer. Do it in a
'use client' module, like the adapters above: the /rich-text entry
carries the client banner, so the factory is a client reference a Server
Component can pass along but cannot call, and the resolver is a closure that
could not cross the boundary as a prop. A hand-written internalDocToHref
still works; if both options are passed, internalDocToHref wins.
'use client'
import { richTextBlockRenderer } from '@bison-lab/payload-blocks/rich-text'
import { resolveSiteLink } from './resolve-site-link'
export const SiteRichText = richTextBlockRenderer({
resolveLink: resolveSiteLink,
})Without a resolver an internal link still renders through the adapter, at #,
with a console error naming the option. A site whose editors link between
documents should not ship that way. An unpopulated or unpublished doc
follows resolveLink's null rule and the helper maps that to # — it does
not invent a second unpublished policy.
An upload an editor drops into the prose goes through the same seams: an
image through imageComponent (via resolveMedia, so width, height and alt
travel), any other file through linkComponent at its url, labelled with the
filename. A bare id — an upload that has not resolved — renders nothing.
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 booleans, overlapAbove and overlapBelow, the same kind of
derived neighbour fact as runIndex and isLast. A package renderer's
band wrapper adds seamClasses(props); 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 RenderBlocks' floats prop, defaulting to
overlapsSeam (the package's stats band with overlap on). A site with a
floating block of its own composes it:
<RenderBlocks … floats={(row) => overlapsSeam(row) || row.blockType === 'bookingCard'} />SEAM_PULL_UP / SEAM_PULL_DOWN are the floating block's negative margins
and OVERLAP_ABOVE_CLEARANCE / OVERLAP_BELOW_CLEARANCE the matching
padding, all from ./react, so a site block that floats uses the same
distance the neighbours clear. A floating block that is first on the page
keeps its top padding, and one that is last keeps its bottom padding rather
than pulling the footer up.
Tailwind has to see the renderers. The padding and margins above are
utilities in this package's output, not in @bison-lab/ui, so a site's
stylesheet scans both packages:
@source '../../../node_modules/@bison-lab/ui/dist';
@source '../../../node_modules/@bison-lab/payload-blocks/dist';Section settings
Every page block carries a settings group: background, spacing, anchor, and
visibility. Header blocks (megaMenu, navLink) do not. The group is
appended by withSectionSettings, which the package page blocks already
pass through. A site with its own approved list wraps again — that replaces
the background options, it does not add a second group:
import { HeroBlock, withSectionSettings } from '@bison-lab/payload-blocks'
withSectionSettings(HeroBlock, {
backgrounds: [
{ label: 'Page', value: 'page' },
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
],
})background stores the key, never a hex. RenderBlocks turns it into
sectionClassName (the spacing class plus the class from backgroundClasses)
and each package renderer puts that on its band, so the air sits inside the
surface. A site-owned renderer has to do the same. page is the untouched
surface and has no class unless the map gives it one. Spacing is the
library's (compact, default, spacious). An anchor becomes the wrapper's
id when it is URL-safe. hidden keeps the row in the editor and leaves it
out of the page, including out of prevType and runIndex. A block's own
fill (the hero tone, the testimonial accent) steps aside when the mapped
background class is present.
The fold is @bison-lab/payload-blocks/admin#SectionSettingsField. Run
payload generate:importmap. Adding the group is a schema change: run
payload generate:types and payload migrate:create.
Wiring a header
Two more blocks build a header rather than a page: megaMenuBlock and
LinkBlock. They go in createNavigation({ blocks })'s header.items.
megaMenuBlock is a factory because the featured-link variants and
the icon list are the site's — the CMS only ever offers approved values.
The items field uses NAV_ITEMS_FIELD (the kit editor) instead of
Payload's stock blocks UI:
import { createNavigation } from '@bison-lab/payload-core'
import { LinkBlock, megaMenuBlock } from '@bison-lab/payload-blocks'
globals: [
...createNavigation({
blocks: [
megaMenuBlock({
variants: [
{ label: 'Lumbar', value: 'lumbar' },
{ label: 'SI joint', value: 'si' },
],
icons: [{ label: 'Help', value: 'help' }],
}),
LinkBlock,
],
}),
]Then payload generate:types, payload generate:importmap, and
payload migrate:create. Until the import map includes
@bison-lab/payload-blocks/admin#NavItemsField and
@bison-lab/payload-blocks/admin#AdminWorkspace, those slots render
as nothing, not the stock blocks UI.
An editor opening Header sees the admin workspace: title strip (Payload
status / Save draft / Publish), a 300px item rail, and a canvas with a
sticky kit preview of the bar (Theme Identity lockup when one exists). Destinations
go through LINK_FIELD. The Header button is the rail slot that
writes header.cta (off until the editor enables it; label and destination
are required to save while it is on). Column widths, read as fractions, must add up to
100%: the rule is the array's own validate, so the editor sees
"The columns add up to 75%. They need to add up to 100%." as they build,
and the global's publish refuses the same. A panel is narrow, standard,
or wide. The mega trigger's landing page is the same type / page /
href picker as every other destination.
In the site header, one call turns the fetched Header global into
FloatingNavBlock props. Looks come from chrome roles
(megaMenuVariantsFromLooks), not a list on Header:
'use client'
import { FloatingNavBlock, megaMenuVariantsFromLooks } from '@bison-lab/ui'
import { headerFromNavigation } from '@bison-lab/payload-blocks/react'
import { usePathname } from 'next/navigation'
export function SiteHeader({ doc }: { doc: Navigation }) {
const pathname = usePathname()
return (
<FloatingNavBlock
{...headerFromNavigation(doc, {
looks: megaMenuVariantsFromLooks(['lumbar', 'si'], {
lumbar: <Marker region="lumbar" />,
si: <Marker region="si" />,
}),
icons: { help: <CircleHelp className="size-4" aria-hidden /> },
isActive: (href) => pathname.startsWith(href),
linkComponent: NextBlockLink,
})}
renderLink={({ href, children, ...rest }) => <Link href={href} {...rest}>{children}</Link>}
/>
)
}headerItemsFromBlocks is still the items half. Public headers must
call headerFromNavigation so hidden bar defaults reach the bar.
Navigation settings
Bar behaviour stays on the Header schema (bar.hideOnScroll,
bar.viewport, bar.defaultMaxWidth) and is hidden in the admin.
The workspace canvas is the Header preview — do not leave a split
live-preview iframe. The kit editor holds the edited mega row open
through openItem. Featured looks stay on the chrome-role registry
(BIS-85 / SPI-89) — Header has no variants array. The Spinal consume
is SPI-99. After this schema change run payload generate:importmap,
payload generate:types, and payload migrate:create.
MegaMenuBlockRenderer renders one panel on its own, which is what a live
preview of the row wants; it is not a page block and has no place in the
registry. A row with no columns, or whose columns hold no complete links,
renders nothing, and headerItemsFromBlocks drops it.
Blocks
| Slug | Renders | Notes |
| --- | --- | --- |
| hero | HeroSweep | Alias of heroSweep for this release. Re-key existing rows to heroSweep. |
| heroSweep | HeroSweep | Tones sweep, dark, and plain (light). overlapBelow adds the bottom clearance. |
| heroCompact | HeroCompact | Image-led. heroCompactBlock({ extraFields }) is where a site adds its own eyebrow source. The library does not name that field. |
| richText | RichText (Lexical) | From /rich-text. Links go through linkComponent; a link to another document needs richTextBlockRenderer({ resolveLink }) (or a hand-written internalDocToHref). Uploads go through imageComponent or linkComponent the same way. |
| showcasePanels | ShowcasePanelsBlock | 3–6 panels, each with a required image. Opens with three. |
| processSteps | ProcessStepsBlock | 3–6 ordered steps, numbered by position; advances on a timer. Opens with three. |
| faqColumns | FAQColumnsBlock | Answers are plain text, not Lexical — see the config for why. |
| testimonialMasonry | TestimonialMasonry | Avatars fall back to initials. |
| nap | NapBlock + JsonLd | Emits schema.org LocalBusiness; phoneE164 is what the tel: link uses. |
| statsBand | StatsBandBlock | 2–6 figures. Phones show two per row, tablets three, columns from lg. overlap floats it over the seam with its neighbours — see "The seam". |
| resources | ResourcesBlock | One required featured card beside a list that opens empty. A card needs a heading, a date (display text) and an author, or it is dropped; no usable featured card renders nothing. The card link is a destination with no label (the heading is the link text). Category colors: register resourcesBlockRenderer({ categoryStyles }). Not in payload-core's Block library catalogue: a site lists it in createBlockLibrary({ blocks }) when it adopts the block (SPI-13 for Spinal). |
| megaMenu | MegaMenuPanel | A header block, from megaMenuBlock({ variants, icons }). See "Wiring a header". |
| navLink | — | A header block: a plain bar item. headerItemsFromBlocks maps it. |
A row whose upload has not resolved is dropped rather than rendered empty, and a block left with nothing to show renders nothing at all. Draft saves skip Payload validation, so live preview genuinely hands renderers incomplete rows.
Samples
Every block ships a sample row, so it can be rendered with no CMS behind it:
import { blockSamples } from '@bison-lab/payload-blocks'
blockSamples.showcasePanels // a ShowcasePanelsBlockData with three panelsblockSamples is one map keyed by blockType, typed per block
(BlockSamples) and assignable to Record<BisonBlockType, BisonBlockData>.
The header blocks are in it too: megaMenu renders through
MegaMenuBlockRenderer, and its sample fits a config built with
megaMenuBlock(megaMenuSampleOptions), the options it was written against;
navLink is data only.
It comes from the main entry and is React-free, so a Global's field config and
a route handler can both read it. The Block library page that renders these
live for an admin is createBlockLibrary in @bison-lab/payload-core; this package ships the
data, not the page.
Each sample has realistic copy at the length the layout was designed for (three showcase panels, four process steps, six testimonials), and sets every field its config has at least once, so a preview shows the block's full range.
Images travel with the sample. No site media exists in a preview, so every
upload in a sample is an inline SVG data: URL in the MediaDoc shape, with
width and height set. resolveMedia accepts it as-is, and the data:
scheme is how an adapter knows what it has: next/image marks a data: src
unoptimized by itself, so the adapter above needs no special case. A site
that wants the same placeholders for its own blocks can build them with
sampleImage({ label, width, height, alt }).
The nap sample sets emitJsonLd: false. The block emits schema.org
LocalBusiness by default, and a preview must not put a fictional clinic into
a site's structured data.
Only what this package ships has a sample here. A site's own blocks supply
theirs through createBlockLibrary({ blocks }). hero, heroSweep, and
heroCompact each have one, so the Block library page can render them.
Conventions for contributors
- No generated types.
src/types.tshand-writes each block's row shape to match what Payload generates from the config beside it. Every field is optional and nullable so a site's generated type is assignable to it, and no interface here has an index signature — TypeScript will not assign an interface to a type that does. src/index.tsstays React-free. Payload loads a site's config outside the bundler, inmigrate,generate:typesand the admin server. An admin component is referenced from a config by its import-map string (MIN_ROWS_ARRAY_FIELD), never imported.src/admin.tsxis the only entry that loads@payloadcms/ui. Where a stock field does most of the job it is wrapped, not re-implemented (MinRowsArrayField), so an upgrade carries every upstream behaviour along. A control Payload has no stock form of (LinkField) is written against Payload's hooks and theme variables and nothing else, so it reads in both admin themes without a stylesheet of its own. Editor-facing copy, accessible names and control text are not config fields: the@bison-lab/uidefaults stand.cn()is off limits. It is a client-only export of@bison-lab/ui; use the localcx().- Changing a field is a schema change in every consuming site. Update
src/types.tsand the field-name lock insrc/__tests__/configs.test.tsin the same change, and say "runpayload migrate:create" in the changeset. - Removing or renaming a block is a major. The changeset names the slug so
a site can grep its rows before upgrading.
orphanBlockTypes(rows, registry)(React-free, from the main entry) lists the types a page still holds that the registry cannot render;RenderBlockswarns once per unknown type in development and callsonUnknownBlockfor every dropped row in every environment. Production stays silent besides the hook. - Every block ships a sample. A new block adds
src/blocks/<slug>/sample.tsbeside itsconfig.tsandcomponent.tsx, typed to its row shape, usingsampleImagefor every upload, and registers it insrc/samples.ts.src/__tests__/samples.test.tsxfails until it does, and again if the sample leaves a config field unset. A new field on an existing block means its sample sets that field too. - A factory block takes only what the site must decide.
megaMenuBlocktakes the approved variants and icons and nothing else; a select whose options come from the site is omitted when the site passes none, so the field-name lock tests the factory with both lists given.
