Your own blocks

Pages are built from blocks: Text, Image, Button and the rest of the ones that come with the cms. When you need something they cannot do, you write a block yourself. By the end of this chapter you will have a block of your own in the editor's block picker, with fields to fill in, styling and a test.

This chapter assumes you can read TypeScript and a little React. Everything is in your site's folder, and you do not touch the cms itself.

Where your blocks live

Your site's blocks/ folder is a block package of its own, registered next to the cms's blocks in site.config.ts. Its blocks are named site/…. A new site comes with a Hero variant and a Counter to copy, and the folder also holds the site's own stylesheet. A command creates the files for a new block:

pnpm cms generate block Feature            # a plain block
pnpm cms generate block Ticker --island    # an interactive block
pnpm cms generate hero Splash              # a hero variant
pnpm cms generate collection team          # a source for the “List of…” block

Run these in your site's folder. Each one prints the files it created or changed, and tells you to restart the development server. Restart it after every new block so the editor picks it up.

Make a plain block

  1. Generate the block:
pnpm cms generate block Feature

The command makes a folder for the block and adds the block to the lists the package keeps. You will rarely need to touch those files:

blocks/
  feature/
    schema.ts         what settings the block has
    fields.ts         how the editor shows them
    render.tsx        what the block draws
    docs.ts           what the block is for, in words
    index.ts          puts the parts together
    feature.test.tsx  a test to start from
  index.ts            lists the package's blocks
  schema.ts           the same list, without React
  client.ts           the same list, for interactive blocks
  1. Restart the development server, open a page in the editor and open the block picker. The block is in the Custom group, as Feature. A block you add to a group that another package owns, such as Hero or Lists, is labelled “Custom: Name” there, so blocks the cms adds later never clash with yours.
  2. Add a setting. The generated block has a heading, text, padding, background and alignment. Add a small label above the heading. First the schema, which lists the block's settings with their types and defaults. The editor's form and the types of the render both come from it:
blocks/feature/schema.ts
import { align, colorToken, responsive, spacingToken } from '@jamiemcconnell/cms/blocks-core';
import { z } from '@jamiemcconnell/cms/blocks';

export const featureSchema = z.object({
  eyebrow: z.string().default(''),
  heading: z.string().min(1).default('Feature'),
  text: z.string().default(''),
  padding: responsive(spacingToken).default('lg'),
  background: colorToken.default('surface'),
  align: align.default('left'),
});
export type FeatureProps = z.infer<typeof featureSchema>;
  1. Say how the editor shows it. Each setting gets a field, built from the helpers the theme provides:
blocks/feature/fields.ts
import type { FieldsContext } from '@jamiemcconnell/cms/blocks';
import { richTextField, textField } from '@jamiemcconnell/cms/blocks-core';

export const featureFields = ({ fields }: FieldsContext) => ({
  eyebrow: textField('Small label above the heading'),
  heading: textField('Heading'),
  text: richTextField('Text'),
  padding: fields.responsive(fields.spacingField('Padding')),
  background: fields.colorField('Background'),
  align: fields.alignField('Alignment'),
});
  1. Draw it. A plain block is a function that gets its settings as props and returns markup. It runs on the server and sends no JavaScript to the browser:
blocks/feature/render.tsx
import { RichTextValue } from '@jamiemcconnell/cms/blocks';
import { rc } from '@jamiemcconnell/cms/blocks-core';
import { cx } from '@jamiemcconnell/cms/theme';
import type { FeatureProps } from './schema.js';

export function Feature({ eyebrow, heading, text, padding, background, align }: FeatureProps) {
  return (
    <section className={cx('wn-feature', rc('py', padding), `wn-bg-${background}`, `wn-align-${align}`)}>
      <div className="wn-container-default wn-px-md">
        {eyebrow ? <p className="wn-feature__eyebrow">{eyebrow}</p> : null}
        <h2 className="wn-font-heading">{heading}</h2>
        <RichTextValue value={text} className="wn-prose" />
      </div>
    </section>
  );
}

Reload the editor. The block has the new field. The padding and background fields use the theme's tokens, so they follow the site's design (Theme).

  1. Describe the block in docs.ts. Replace the placeholder text in description with a sentence or two on what the block is for and when to pick it over a neighbour. The examples should be valid uses of the block. This is also how an AI assistant such as Claude learns what the block does (AI assistants (MCP)).
  2. Run the tests:
pnpm test

The generated test renders the block with the site's test theme and checks the HTML. Add a case of your own inside its describe block:

blocks/feature/feature.test.tsx
it('shows the small label only when there is one', () => {
  expect(renderBlock(Feature, { eyebrow: 'New' })).toContain('wn-feature__eyebrow');
  expect(renderBlock(Feature, {})).not.toContain('wn-feature__eyebrow');
});

Style it

The package's styles function, in blocks/index.ts, takes the theme and returns CSS. It is added after the theme's own CSS on every page. Use it for what tokens cannot say, with the theme's variables to hand. A new site's function already holds rules for cards, menus and prose; add yours to the end of the string:

blocks/index.ts
styles: (theme) => /* css */ `
/* …the rules that were already here… */
.wn-feature__eyebrow{margin:0 0 var(--spacing-xs);font-size:.85rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--color-muted)}
@media (width < ${theme.breakpoints.md}){
  .wn-feature h2{font-size:1.5rem}
}
`,

Make an interactive block

A plain block is turned into HTML on the server and ships no JavaScript. An interactive block is called an island. It is also drawn on the server, then hydrated in the browser, which means its component is loaded and React attaches to the markup already on the page. Nothing is drawn twice and the page does not jump. From then on it is an ordinary React component with its own state. A page without islands loads no JavaScript at all, and a page with them loads only the code of the islands it contains.

Generate one with --island:

pnpm cms generate block Ticker --island

The folder has island.tsx where a plain block has render.tsx, and the block's index.ts says when to hydrate it:

blocks/ticker/index.ts
export default defineBlock({
  name: 'Ticker',
  // …
  render: Ticker,
  island: { hydrate: 'visible', component: () => import('./island.js') },
});

hydrate is load (straight away, for controls used at once, like a sign-in form), visible (when the block scrolls into view, which the generated blocks use) or idle (when the browser has nothing better to do). A block can also hydrate only part of itself: the Form block keeps its fields static and hydrates only its send button, with islands: ['FormSubmit'].

Four rules keep an island working:

  • The first render must match the server's. Start the state from props only. Do not call Date.now(), Math.random() or touch window while rendering. Put browser-only work in effects and event handlers, or hydration will not match.
  • Ask for what is about the visitor up front. An island that depends on who is looking, such as who is signed in or what is in their basket, lists the page-metadata keys it needs in island.metadataProps, for example metadataProps: ['viewer']. The server fills them in when it draws the page, so the first paint is right, and the island only fetches again to confirm an empty answer. The keys are viewer, profile, cart, orders and claim, plus any a module adds.
  • Send the CSRF token when you write. An island that changes something posts to the site's API with the CSRF token, as the basket and the forms do. Modules shows the pattern.
  • Keep the words in fields. Labels and messages are settings of the block, as the Counter's are, so editors can change them without a deploy.

The Counter on the starter's home page is the smallest example. Its first frame is server HTML and it hydrates when it scrolls into view. Its state lives in the browser tab, so a reload starts it again from its Starts at setting.

Make a hero variant

A hero is the large band at the top of a page. The command makes a block shaped like the cms's hero blocks:

pnpm cms generate hero Splash

It is listed under Hero in the picker as “Custom: Splash”, and otherwise works like any block.

Make a source for “List of…”

The “List of…” block shows a repeating set of things, such as a team, using any blocks you drop inside it. Its sources are collections (Collections). You can define one in code:

pnpm cms generate collection team

This writes blocks/collections/team.ts and a test. The file declares the fields of each item, optional filters and sorts, a load function that returns the items at request time, and a sample item for the editor to preview with. The generated load reads three made-up rows; replace them with a JSON file, a table or an API. Once it is registered, “Team” appears as a source in “List of…”.