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.
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…” blockRun 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.
pnpm cms generate block FeatureThe 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 blocksimport { 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>;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'),
});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).
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)).pnpm testThe 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:
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');
});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:
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}
}
`,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 --islandThe folder has island.tsx where a plain block has render.tsx, and the block's index.ts says when to hydrate it:
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:
Date.now(), Math.random() or touch window while rendering. Put browser-only work in effects and event handlers, or hydration will not match.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.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.
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 SplashIt is listed under Hero in the picker as “Custom: Splash”, and otherwise works like any block.
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 teamThis 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…”.