I wanted people to install my components the same way they install shadcn/ui ones — one command, zero manual file hunting. Turns out shadcn has a registry protocol built exactly for this. Here's the complete setup.
What the registry actually is
At its core it is a JSON contract. When someone runs:
npx shadcn@latest add https://yoursite.com/r/copy-buttonnpx shadcn@latest add https://yoursite.com/r/copy-buttonthe CLI fetches https://yoursite.com/r/copy-button.json, reads the component
source embedded inside, and writes the files into their project. That JSON file is
the registry item. Everything else is just scaffolding to produce it and serve it.
Folder structure
your-project/
├── registry/
│ └── ui/
│ └── copy-button.tsx ← source of truth
├── registry.json ← registry manifest
└── public/
└── r/
└── copy-button.json ← built output (what the CLI fetches)your-project/
├── registry/
│ └── ui/
│ └── copy-button.tsx ← source of truth
├── registry.json ← registry manifest
└── public/
└── r/
└── copy-button.json ← built output (what the CLI fetches)Keep the source in registry/ui/ and commit it. The public/r/ output is
generated — you can gitignore it or commit it, both work.
Step 1 — Write the component
Write your component as you normally would, except import from paths that make sense inside a consumer's project, not your own:
// registry/ui/copy-button.tsx
'use client'
import { IconCheck, IconCopy } from '@tabler/icons-react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
interface CopyButtonProps {
value: string
className?: string
}
export function CopyButton({ value, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy'}
className={cn('...', className)}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</button>
)
}
// registry/ui/copy-button.tsx
'use client'
import { IconCheck, IconCopy } from '@tabler/icons-react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
// registry/ui/copy-button.tsx
interface CopyButtonProps {
value: string
className?: string
}
export function CopyButton({ value, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy'}
className={cn('...', className)}
>
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</button>
)
}
The @/lib/utils import is intentional — it will resolve correctly in the
consumer's project as long as they have a standard shadcn setup.
Step 2 — Create registry.json
At the root of your project, create registry.json. This is the manifest the
build command reads:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "lalit",
"homepage": "https://lalitkakkar.vercel.app",
"items": [
{
"name": "copy-button",
"type": "registry:ui",
"title": "Copy Button",
"description": "A minimal button that copies text to clipboard with a smooth icon transition.",
"dependencies": ["@tabler/icons-react"],
"registryDependencies": ["tooltip"],
"files": [
{
"path": "registry/ui/copy-button.tsx",
"type": "registry:ui",
"target": "components/ui/copy-button.tsx"
}
]
}
]
}{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "lalit",
"homepage": "https://lalitkakkar.vercel.app",
"items": [
{
"name": "copy-button",
"type": "registry:ui",
"title": "Copy Button",
"description": "A minimal button that copies text to clipboard with a smooth icon transition.",
"dependencies": ["@tabler/icons-react"],
"registryDependencies": ["tooltip"],
"files": [
{
"path": "registry/ui/copy-button.tsx",
"type": "registry:ui",
"target": "components/ui/copy-button.tsx"
}
]
}
]
}A few things worth knowing:
nameat the top level is your registry identifier, not a component nameregistryDependenciestakes shadcn component names like"tooltip"or"button"— the CLI installs them automaticallydependenciesis for npm packages (e.g.["@tabler/icons-react"])targetis where the file lands in the consumer's project
Step 3 — Build the registry
npx shadcn@latest buildnpx shadcn@latest buildThat's it. The CLI reads registry.json, reads every file listed under files,
embeds the source as a string, and writes the output to public/r/.
The generated public/r/copy-button.json looks like:
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "copy-button",
"type": "registry:ui",
"title": "Copy Button",
"description": "...",
"registryDependencies": ["tooltip"],
"files": [
{
"path": "registry/ui/copy-button.tsx",
"type": "registry:ui",
"target": "components/ui/copy-button.tsx",
"content": "'use client'\n\nimport { IconCheck ..."
}
]
}{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "copy-button",
"type": "registry:ui",
"title": "Copy Button",
"description": "...",
"registryDependencies": ["tooltip"],
"files": [
{
"path": "registry/ui/copy-button.tsx",
"type": "registry:ui",
"target": "components/ui/copy-button.tsx",
"content": "'use client'\n\nimport { IconCheck ..."
}
]
}The content field is the entire source file as a string. The CLI on the
consumer's end writes that string to target path in their project.
Add a build script to package.json so you never forget to rebuild:
{
"scripts": {
"registry:build": "shadcn build",
"prebuild": "shadcn build"
}
}{
"scripts": {
"registry:build": "shadcn build",
"prebuild": "shadcn build"
}
}The prebuild hook means Vercel (or any CI) always has fresh registry files
before the Next.js build runs.
Step 4 — Serve the files
Since public/r/ is inside Next.js's static directory, no extra work needed.
Deploy and your files are live at:
https://yoursite.com/r/copy-button.jsonhttps://yoursite.com/r/copy-button.jsonStep 5 — Installing from the registry
Anyone can now run:
npx shadcn@latest add https://yoursite.com/r/copy-buttonnpx shadcn@latest add https://yoursite.com/r/copy-buttonThe .json extension is optional — shadcn appends it automatically.
If you want a cleaner install URL, point a custom domain subdomain at the
same deployment. registry.yourdomain.com/r/copy-button reads nicer than
the full Vercel URL.
Adding more components
Adding a second component is three steps:
- Write the component in
registry/ui/your-component.tsx - Add an entry to
registry.json - Run
npm run registry:build
Each component gets its own JSON file. If one component depends on another from
your registry, list it in registryDependencies using the full URL:
"registryDependencies": [
"tooltip",
"https://yoursite.com/r/copy-button.json"
]"registryDependencies": [
"tooltip",
"https://yoursite.com/r/copy-button.json"
]The CLI resolves remote registry deps the same way it resolves shadcn ones.
Keeping things in sync
The only footgun is forgetting to rebuild after editing a source file. The
prebuild hook handles production. For local dev, wire it into your watch
script or just run npm run registry:build before committing.
If you have a pre-commit hook, add it there:
{
"lint-staged": {
"registry/ui/**/*.tsx": ["npm run registry:build"]
}
}{
"lint-staged": {
"registry/ui/**/*.tsx": ["npm run registry:build"]
}
}That way the public/r/ files are always in sync with registry/ui/ before
anything gets pushed.
The result
The components on my portfolio are
all installable this way. The source stays in registry/ui/, the built JSON
lives in public/r/, and the CLI does the rest. No npm publish, no separate
package to maintain — just a JSON file served from a folder.