Add a block
A layout block is a folder in src/blocks, registered once and offered on every layout. The tests make sure nothing is forgotten: a block without a renderer fails the type check, and one without a sample or demo content fails a test.
Take src/blocks/CTABanner as a model: a block with the standard tabs, a header and buttons.
1. The config #
src/blocks/<Name>/config.ts exports a Payload Block:
import type { Block } from 'payload'
import { blockTabs } from '@/fields/blockTabs'
import { sectionHeaderGroup } from '@/fields/sectionHeader'
import { BLOCK_GROUPS } from '../groups'
export const QuoteWallBlock: Block = {
slug: 'quoteWall',
interfaceName: 'QuoteWallBlock',
labels: { singular: 'Quote wall', plural: 'Quote walls' },
admin: { group: BLOCK_GROUPS.content },
fields: [
blockTabs({
header: sectionHeaderGroup(),
content: [
// the block's own fields
],
}),
],
}
interfaceNamenames its type insrc/payload-types.ts. Required.admin.groupis one ofBLOCK_GROUPS(src/blocks/groups.ts): where the block sits in the picker. Required.blockTabs()(src/fields/blockTabs.ts) gives the tabs every block shares: Header, Content, Footer, Look (background colour, surface width, background image, content width), Settings (anchor, spacing, minimum height) and Visibility (languages and screen sizes). Passfooter: nullfor a block without the footer buttons.- Text editors write is localized (
localized: true) and labels and descriptions are short, plain English. - Translation hints:
custom: { translate: false }keeps a text field that is not prose (a code, a URL) out of AI translation;custom: { register: 'headline' }or'cta'tells the review pass what kind of text it is. A field each language sets for itself takescustom: { blockSync: false }(see block sync).
2. The renderer #
src/blocks/<Name>/Component.tsx exports a React component that takes the block's fields and the BlockContext (the language, whether it is nested, the document it sits on):
import type { BlockContext } from '@/blocks/RenderBlocks'
import { BlockSection, blockSurface } from '@/components/sections/BlockSection'
import { SectionHeader } from '@/components/sections/SectionHeader'
import type { QuoteWallBlock as Props } from '@/payload-types'
export function QuoteWallBlock({ nested, ...props }: Props & BlockContext) {
const surface = blockSurface(props)
return (
<BlockSection frame={props} nested={nested}>
<SectionHeader data={props.header} accentClass={surface.accent} />
{/* … */}
</BlockSection>
)
}
- Use the shared pieces in
src/components/sections:BlockSectiondraws the surface, spacing and width from the Look and Settings tabs;SectionHeaderandSectionFooterdraw the header and buttons. - Text colours come from the surface (
blockSurface,getSurface), never fixed white or black. Text on an image goes onTextScrim. - Motion comes from attributes:
data-revealon what should come into view as the visitor scrolls,data-reveal-mediaon a picture's frame.SectionHeaderbrings its own. See Scroll animations. - Visitor-facing words (button labels, "Read more") come from next-intl keys in
src/messages/en.json,da.jsonandde.json, the same keys in all three. - Never import
@/fields/*at runtime in a component (import typeis fine): it pulls server-only code into the page. A test checks it.
3. Register it #
- Add the config to the list in
LAYOUT_BLOCKSinsrc/blocks/index.ts. Pages, articles, case studies, products, product families and reusable sections all offer it from there. - Add the renderer to the
componentsmap insrc/blocks/RenderBlocks.tsx, keyed by slug. - If the block belongs to a module (it lists news, say), add its slug to that module's
blocksinsrc/registry/modules.ts, so it goes when the module is switched off.
Then regenerate the types (and the import map, if the block has admin components of its own):
pnpm generate:types
pnpm generate:importmap
CI fails when the committed src/payload-types.ts or import map is not current.
4. A picture for the picker #
Add public/block-previews/<slug>.svg: a 320 × 200 wireframe in greys, like its neighbours. The picker shows it above the block's name, and a test fails while a block has none.
5. A gallery sample #
tests/blocks/<slug>.ts exports a BlockSample: the block's data, filled in, and optionally a check that looks at it on the page.
import { expect } from '@playwright/test'
import type { BlockSample } from './types'
export const quoteWall: BlockSample = {
slug: 'quoteWall',
data: ({ image }) => ({ blockType: 'quoteWall' /* , … */ }),
check: async ({ root }) => {
await expect(root.getByRole('heading', { level: 2 })).toBeVisible()
},
}
Add it to BLOCK_SAMPLES in tests/blocks/index.ts. The end-to-end gallery (tests/e2e/blocks.e2e.spec.ts) puts every sample on one page and fails when a registered block has no sample, a block does not render, or the page logs an error. The data function gets a context with ready-made content: an image, a video, a form, pages, products and more (SampleContext in tests/blocks/types.ts).
6. Demo content #
Put the block on a page of the demo in scripts/demo/pages.ts, with its English and Danish text: t('English', 'Dansk'). A module's block goes in only when its module is on (jobs && { … }). tests/int/demoSeed.int.spec.ts fails while a registered block is on no published demo page.
7. Update the snapshot #
tests/int/modules.int.spec.ts holds an inline snapshot of every block and collection slug. Run pnpm test:int and update it with the new slug.
Small blocks #
Blocks inside a Columns column or an accordion row come from a pool of small blocks in src/blocks/_micro. A small block of its own needs a config and a frameless component in src/blocks/_micro/<Name>, an entry in MICRO_LEAF_BLOCKS (src/blocks/_micro/base.ts), a case in MicroBlockRenderer.tsx, a group and an interfaceName, and a preview SVG. A layout block can be offered as a small block too, as File list, Accordion and Article list are: list its config in MICRO_BLOCK_BASE_SLUGS and give it a frameless component in _micro.
Checklist #
-
src/blocks/<Name>/config.tsandComponent.tsx -
LAYOUT_BLOCKSand thecomponentsmap inRenderBlocks.tsx -
pnpm generate:types(andgenerate:importmap) -
public/block-previews/<slug>.svg -
tests/blocks/<slug>.tsinBLOCK_SAMPLES - the block on a page in
scripts/demo/pages.ts - message keys in all three files, if visitors read any of its words
- the snapshot in
tests/int/modules.int.spec.ts -
pnpm lint,pnpm typecheck,pnpm test:unit,pnpm test:int, then the end-to-end tests