How to Build Your Own Shadcn UI Registry
Source: Dev.to
After 4 weeks building 30+ landing‑page blocks (hero, pricing, FAQ, CTA, testimonials), I finally cracked the perfect Shadcn registry setup.
A walkthrough of modeling blocks (Hero, Pricing, FAQ, etc.), wiring them to a registry.json, rendering server‑friendly previews, and preparing them for npx shadcn add.
1. Introduction
Shadcn UI ships with an official registry of components and blocks, but you can also build your own registry tailored to your layouts and design system.
After 4 weeks of iteration, I built a landing‑page‑focused registry with 30+ blocks like hero, pricing, FAQ, CTA, and more – all described in a registry.json and rendered in a generic /blocks/[name] page with live preview and syntax‑highlighted code.
Metrics: 30+ blocks across 12 categories, 50 k+ lines of Tailwind, 100 % server‑component compatible.
We’ll cover:
- How a single block (
Hero01) is structured - How that block is described in
registry.json - How a generic Next.js page
/blocks/[name]renders any block by name - How a
BlockProviderinjects theme, screen size, and preview behavior without touching the app’s global theme - How this design plays nicely with React Server Components and the Shadcn CLI
2. What is a Shadcn “registry block”?
At a high level, a Shadcn registry item is JSON metadata that points to one or more code files and describes their dependencies.
| Type | Role | Example value |
|---|---|---|
| Component | Low‑level primitive (button, input) | registry:component |
| Block | Page section (hero, pricing, FAQ) | registry:block |
In my registry: 30 page sections (hero ×5, pricing ×3, features ×5, CTA ×4, etc.) modeled as registry:block.
Metric: Each block averages 3–5 Shadcn primitives (button, badge, card) via registryDependencies.
3. A concrete block: Hero01
Here’s my actual Hero01 – a two‑column hero with badge, title, description, two CTAs, and an image:
interface Hero01Props {
badge?: string;
heading?: string;
description?: string;
buttons?: {
primary?: { text: string; url: string; icon?: React.ReactNode };
secondary?: { text: string; url: string; icon?: React.ReactNode };
};
image?: string;
className?: string;
}
Key design decisions
- Marketing props only – no low‑level layout knobs
- Purely presentational – 0 hooks, 0 data fetching
- Server‑component ready – no
use clientdirective - Composes Shadcn – uses
Button,Badge+ Tailwind grid
Usage
Metric: Hero01 = 187 lines, 12 Tailwind utility classes, 2 Shadcn components.

4. Describing the block in registry.json
My hero-01 registry entry:
{
"name": "hero-01",
"type": "registry:block",
"title": "Hero 01",
"description": "Two-column hero with badge + CTAs",
"dependencies": ["lucide-react"],
"registryDependencies": ["button", "badge"],
"categories": ["hero"],
"meta": { "image": "/r/previews/hero-01.webp" },
"files": [
{
"path": "src/registry/blocks/hero-01/hero.tsx",
"target": "components/hero-01.tsx"
}
]
}
Key fields
registryDependencies→ CLI auto‑installsbutton+badgecategories→ Powers/blocks?category=herofilteringfiles.target→ Predictablecomponents/hero-01.tsxlocation
Metric: 30 blocks = 12 categories, 47 total dependencies (npm + registry).
5. The BlockProvider: Isolated preview magic
Problem: Preview 30+ blocks with theme switching + responsive breakpoints without breaking the site theme.
Solution: BlockProvider creates a self‑contained preview universe:
interface BlockContextValue {
block: SerializableRegistryBlock;
theme: Theme;
screenSize: ScreenSize; // 'mobile' | 'tablet' | 'desktop'
setTheme: (theme: Theme) => void;
setScreenSize: (size: ScreenSize) => void;
}
Why it works
- ✅ Theme changes stay inside
/blocks/hero-01 - ✅ Screen‑size toggle = instant CSS viewport classes
- ✅ 30+ blocks share one preview system
- ✅ Global site theme remains completely untouched
Metric: Provider handles 5 themes × 3 breakpoints = 15 preview variations per block.
6. Server components + registry = ⚡ Performance
Server‑first design
- Blocks = server components (no
use client) - Registry JSON = static, build‑time readable
- Preview shell = minimal client boundary
Results
✅ Bundle size: Hero01 = 2.1 KB (gzipped)
✅ TTFB: /blocks/hero-01 = 89 ms
✅ Static pages: 30+ generated at build time
The preview shell (BlockProvider and its controls) stays client‑side only where needed.
7. One route for the entire registry
By using a single dynamic route (pages/blocks/[name].tsx or app/blocks/[name]/page.tsx), every block can be rendered on demand:
// pages/blocks/[name].tsx
import { getBlockByName } from '@/registry';
import BlockProvider from '@/components/BlockProvider';
export default async function BlockPage({ params }) {
const block = await getBlockByName(params.name);
return (
<BlockProvider block={block}>
{/* Render the block component dynamically */}
</BlockProvider>
);
}
- No need for a separate page per block.
- Keeps the codebase DRY and makes adding new blocks as simple as updating
registry.json.
TL;DR
- Define each UI section as a
registry:blockinregistry.json. - Implement the block as a server component that only accepts marketing‑level props.
- Render any block through a single dynamic route wrapped in
BlockProviderfor isolated theme & viewport previews. - Enjoy fast builds, tiny bundles, and a workflow that meshes perfectly with the Shadcn CLI.
Happy building! 🚀
Catalog: /blocks/[name]
Single Next.js page powers all 30+ blocks
export async function generateStaticParams() {
return getBlocks().map(block => ({ name: block.name })); // 30+ pages!
}
export default async function BlockPage({ params }) {
const block = getBlock(params.name);
const { component, ...serializableBlock } = block; // Server‑safe
return (
<BlockProvider block={serializableBlock}>
{/* Render the block component */}
</BlockProvider>
);
}
Auto‑generates: /blocks/hero-01, /blocks/pricing-01, /blocks/cta-01, …
Metric: 22 static pages, 100 % server‑rendered, full SEO metadata.
8️⃣ CLI integration
npx shadcn add @shadcnship/hero-01
My registry follows the Shadcn schema exactly, so CLI integration is seamless:
# Direct URL (works now)
npx shadcn add https://mysite.com/r/hero-01.json
# Namespaced (after PR approval)
npx shadcn add @shadcnship/hero-01
CLI does:
- Copies
hero.tsx→components/hero-01.tsx - Installs
lucide-react - Auto‑installs
button+badge
✅ 30 seconds total
9️⃣ Lessons learned (after 4 weeks + 30 blocks)
✅ Do
- Start with marketing props only (
heading,buttons). - Use
registryDependenciesreligiously. - Keep blocks server‑component first.
- Build the preview system before scaling blocks.
❌ Don’t
- Expose layout props (
padding,gap). - Mix preview + site‑theme state.
- Hard‑code block names in preview pages.
- Forget a
classNameprop for overrides.
Biggest win: Generic /blocks/[name] + BlockProvider = zero extra code per block.
Get the full code
30+ production‑ready blocks with preview system, theme switching, and registry:
👉 GitHub:
👉 Live demo:
Thank you for your time.
Enjoy! 🚀
