Contents

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
      ],
    }),
  ],
}
  • interfaceName names its type in src/payload-types.ts. Required.
  • admin.group is one of BLOCK_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). Pass footer: null for 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 takes custom: { 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: BlockSection draws the surface, spacing and width from the Look and Settings tabs; SectionHeader and SectionFooter draw the header and buttons.
  • Text colours come from the surface (blockSurface, getSurface), never fixed white or black. Text on an image goes on TextScrim.
  • Motion comes from attributes: data-reveal on what should come into view as the visitor scrolls, data-reveal-media on a picture's frame. SectionHeader brings its own. See Scroll animations.
  • Visitor-facing words (button labels, "Read more") come from next-intl keys in src/messages/en.json, da.json and de.json, the same keys in all three.
  • Never import @/fields/* at runtime in a component (import type is fine): it pulls server-only code into the page. A test checks it.

3. Register it #

  • Add the config to the list in LAYOUT_BLOCKS in src/blocks/index.ts. Pages, articles, case studies, products, product families and reusable sections all offer it from there.
  • Add the renderer to the components map in src/blocks/RenderBlocks.tsx, keyed by slug.
  • If the block belongs to a module (it lists news, say), add its slug to that module's blocks in src/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.

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.ts and Component.tsx
  • LAYOUT_BLOCKS and the components map in RenderBlocks.tsx
  • pnpm generate:types (and generate:importmap)
  • public/block-previews/<slug>.svg
  • tests/blocks/<slug>.ts in BLOCK_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

These guides ship with Tolk Starter 0.6.0: buyers get the same pages in their repository, next to the code they describe.