Skip to content

Adapters

Three facts only the site knows, so the package takes each as a prop on RenderBlocks and never guesses: how an image gets optimised (imageComponent), which router renders a link (linkComponent), and what path a page lives at (resolveLink). The package has no next dependency, because next/image needs per-site configuration it cannot supply, so a Next site writes these once and hands them in.

All three live in a "use client" module. Every @bison-lab/ui export is a client reference, and the blocks are interactive anyway. That is also what makes the seams work: a Server Component can hand a client component reference across the boundary, but not a closure, and resolveLink is a closure.

blocks/adapters.tsx
"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 images.localPatterns (or remotePatterns) in next.config.ts to match before any CMS image renders.

Sample images are inline SVG data: URLs. next/image treats a data: src as unoptimized on its own, so the adapter needs no special case for a preview.

"use client";
import Link from "next/link";
import type { BlockLinkComponent } from "@bison-lab/payload-blocks/react";
export const NextBlockLink: BlockLinkComponent = ({ newTab, ...props }) => <Link {...props} />;

Every href a block emits goes through this component: a hero call to action, a showcase panel’s corner action, a NAP tel: link, a menu, a link typed into a rich text section. newTab arrives as a flag and as target and rel already expanded from it, so the adapter only drops the flag before spreading.

Without an adapter, DefaultBlockLink renders a plain anchor and sets target and rel together whenever newTab is on, and DefaultBlockImage renders a plain <img>.

A link a block asks for is stored as type, page and href; see Links for the rule. resolveLink turns that destination into the href the link component receives, or null when there is nothing to point at. The package’s own resolver makes a published, populated page /<slug>. A site whose routes differ wraps it:

"use client";
import { resolveLink, type ResolveLink } from "@bison-lab/payload-blocks/react";
import { pagePath } from "@/lib/routes"; // the 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);

Falling through to the package’s resolveLink keeps its null rule for missing and unpublished pages and its pass-through for external links, so the site only spells the one thing it knows.

The same function serves the rich text block. A link an editor makes to another document arrives as a Lexical node rather than a link row, and richTextBlockRenderer({ resolveLink: resolveSiteLink }) unpacks it and calls this resolver, so the page path is defined once.

A library block that emits an anchor takes renderLink for the same reason, and the renderer hands the seam through with renderLinkWith(linkComponent). A site rendering a library block directly, outside the CMS, can use the same helper:

import { renderLinkWith } from "@bison-lab/payload-blocks/react";
<FAQColumnsBlock {...props} renderLink={renderLinkWith(NextBlockLink)} />