Blocks
114 copy-paste sections in 14 categories, each a real file whose source is read from disk at build time.
What a block is
One file, one catalog entry, one rendered preview, one visible source. The code shown beside each block at /blocks is read from that file by a Server Component during the build — not a copy maintained alongside it — so what you copy is what renders above it. There is no way for the two to drift.
That definition also makes the count a fact rather than a claim. The index renders catalog.length, and src/lib/blocks/blocks.test.ts asserts the three-way match in both directions — so the number cannot be inflated by metadata for a block that does not exist, nor deflated by a file nobody catalogued.
Adding a block
// 1. The file. "use client" is required — the previews render from a
// Server Component, and most blocks use state or Recharts.
// src/components/blocks/stats/stat-sparkline-row.tsx
"use client";
export function StatSparklineRow() { /* … */ }
// 2. The catalog entry. src/lib/blocks/catalog.ts
{
id: "stat-sparkline-row",
name: "Sparkline row",
category: "stats",
description: "Eight trends visible at once, for a page with no room for cards.",
file: "components/blocks/stats/stat-sparkline-row.tsx",
},
// 3. The category registry. src/lib/blocks/registry.stats.ts
export const statsRegistry: BlockRegistry = {
...,
"stat-sparkline-row": StatSparklineRow,
};Miss step 2 or 3 and the test names exactly what is missing. Miss the "use client" and it is caught by name too, rather than surfacing as a hooks error pointing at a primitive.
Why one registry per category
A single registry holding every block would be imported by every category page, so opening Stats would download the charts and the tables too — the catalog would get slower with each block added, which is the opposite of what a growing library should do. So catalog.ts holds pure metadata (feeding the index, the counts, ⌘K and the sitemap) and each registry.<category>.ts holds only its own components.
That is also why there is one route file per category rather than a [category] dynamic route: a dynamic route compiles to one shared page component, which would have to import every registry to switch on the parameter.
Adding a category
// src/lib/blocks/catalog.ts — the id becomes the route segment
export const BLOCK_CATEGORIES = [
...,
{ id: "navigation", name: "Navigation", description: "Tabs, steps and breadcrumbs." },
] as const;
// src/lib/blocks/registry.navigation.ts
export const navigationRegistry: BlockRegistry = { /* … */ };
// src/app/(dashboard)/blocks/navigation/page.tsx — six lines
export default function Page() {
return <BlockCategoryPage category="navigation" registry={navigationRegistry} />;
}The sitemap picks it up automatically: the block category routes are derived from BLOCK_CATEGORIES, not listed by hand.
Two limits worth knowing
- Keep a category near a dozen blocks.Each block's source adds roughly 3KB gzipped to its category page — small, but linear. A five-block chart category is already 44KB gzipped.
- The previews are not responsive previews. Blocks use Tailwind viewport breakpoints, so a
max-widthwrapper cannot trigger them — a width toggle would narrow the box while the block still rendered its desktop layout, which is a preview that lies. Resize the window instead.