How the registry works
A shadcn registry is not a package. It is a set of static JSON files served over HTTP, each containing the literal source of a component as a string alongside its dependencies. The CLI fetches one, writes the files into your repository, rewrites import aliases to match your components.json, and installs the npm packages it names.
That is the whole mechanism. No runtime, no resolver, no node_modules entry.
The two schemas
registry.json is the catalog at the repository root. Ours only lists where the real entries live:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "kiota",
"homepage": "https://ui.kiotapay.com",
"include": [
"registry/lib/registry.json",
"registry/hooks/registry.json",
"registry/components/custom/registry.json"
]
}include composes the catalog from several files instead of one growing array. Each included path must point at a file literally named registry.json, and paths inside it cannot use ../ — an included catalog can only describe files at or below its own directory. That constraint is what determines the layout below.
registry-item.json is one entry per item:
{
"name": "status-badge",
"type": "registry:component",
"title": "Status Badge",
"description": "Payment status pill.",
"dependencies": ["class-variance-authority"],
"registryDependencies": ["badge", "{REGISTRY_URL}/r/kiota-theme.json"],
"files": [
{
"path": "status-badge.tsx",
"type": "registry:component",
"target": "@components/custom/status-badge.tsx"
}
]
}Repository layout
Everything published lives under registry/, which mirrors the tree a consumer ends up with. Everything outside it belongs to the docs site and is never distributed:
registry.json ← root catalog, include[] only
registry/ ← the published surface
components/custom/
registry.json ← sub-catalog
money-input.tsx
status-badge.tsx
copy-field.tsx
hooks/
registry.json
use-copy.ts
lib/
registry.json ← also holds kiota-theme + kiota-style
kiota-format.ts
components/ ← docs site, never published
ui/ ← vendored shadcn primitives
site/ ← CodeBlock, ComponentPreview, sidebar, …
examples/ ← the demos rendered in previews
lib/
utils.ts ← vendored shadcn `cn`
nav.ts highlight.ts registry.ts ← docs site
app/(docs)/ ← documentation pages
public/r/ ← generated output, served at /r/The rule is one directory deep: if it is under registry/, it ships; if it is not, it does not. Reviewing "what am I publishing?" means reading one directory, and components/site/ may import from registry/ while nothing in registry/ may import back out.
components/ui/ and lib/utils.ts are vendored rather than ours. They sit at exactly those paths because that is where components.json aliases point and where a consumer's copies land — which is what makes @/components/ui/input name the same file on both sides.
Because registry/ mirrors the consumer tree, a component here imports exactly what a consumer's copy imports:
import { Input } from '@/components/ui/input' // shadcn primitive
import { formatMoney } from '@/lib/kiota-format' // ours, publishedNeither needs rewriting to be correct on the other side. tsconfig.json maps the published prefixes into registry/ and leaves everything else at the project root:
{
"paths": {
"@/components/custom/*": ["./registry/components/custom/*"],
"@/hooks/*": ["./registry/hooks/*"],
"@/lib/kiota-format": ["./registry/lib/kiota-format"],
"@/*": ["./*"]
}
}TypeScript matches the most specific pattern, so @/lib/kiota-format resolves into the registry while @/lib/utils and @/lib/nav stay at the root.
These aliases are deliberately narrow rather than a blanket "@/*": ["./registry/*", "./*"] fallback. Blanket
shadowing silently resolved @/lib/registry to the catalog registry/lib/registry.json instead of the docs module of
the same name. Listing prefixes explicitly costs one line per published lib file and fails loudly — module not
found — instead of quietly resolving to the wrong thing.
kiota-theme and kiota-style live in registry/lib/registry.json rather than a directory of their own. A catalog cannot reference files above itself, and kiota-theme ships kiota-format.ts, so its entry has to sit beside that file.
Landing in custom/ instead of ui/
Two fields control the destination.
type sets the default: registry:ui writes to your ui alias, registry:component to your components alias, registry:hook to hooks, registry:lib to lib.
files[].target overrides it, and alias-prefixed targets resolve against the consumer's own components.json:
| Target | Resolves to |
|---|---|
@components/custom/foo.tsx | <aliases.components>/custom/foo.tsx |
@ui/foo.tsx | <aliases.ui>/foo.tsx |
@hooks/use-foo.ts | <aliases.hooks>/use-foo.ts |
@lib/format.ts | <aliases.lib>/format.ts |
~/some/path.ts | project root, verbatim |
Every item here uses registry:component with a @components/custom/… target.
Do not use registry:ui for your own components. That type writes into components/ui/, the directory shadcn owns —
an item named button would overwrite a consumer's shadcn button.tsx without warning.
Depending on shadcn
Two separate mechanisms, worth keeping distinct.
registryDependencies pulls the primitives in. A bare name always resolves against the official shadcn registry, never against ours — "badge" means shadcn's badge.
For dependencies on our own items we use absolute URLs, stamped at build time from NEXT_PUBLIC_REGISTRY_URL. The two shorter-looking alternatives both fail:
"@kiota/kiota-theme"requires the consumer to have registered the namespace already — which a project being set up withshadcn inithas not."./kiota-theme.json"is resolved by the CLI as a local file path relative to the consumer's working directory, not relative to the item's URL. It is for local file registries only; over HTTP it fails withENOENT.
Absolute URLs work in every case, so items here install correctly with no configuration at all. The catalogs carry a {REGISTRY_URL} placeholder, and scripts/stamp-registry-url.mjs substitutes it after shadcn build.
Import rewriting handles the source. Author against the standard aliases:
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'The CLI rewrites those to match the consumer's aliases, so a project using src/ gets correct paths.
Cross-references between our own items are resolved through each file's declared target, not by string-matching the source directory. An import of @/registry/kiota/probe-b in a file whose sibling targets @components/custom/probe-b.tsx is delivered as @/components/custom/probe-b. Source layout and destination layout are therefore free to differ.
We do not rely on that here: because registry/ already mirrors the consumer tree, our imports are consumer-correct as written and pass through untouched. The rewriting is a safety net rather than a load-bearing mechanism, which is one less thing to get wrong.
Building and serving
npm run registry:buildThat is four steps in order:
tokens:build— parses the marked blocks inapp/globals.cssinto each item'scssVars.registry:clean— deletespublic/r/outright.shadcn build— reads the root catalog, resolves everyinclude, inlines each file's contents, and writes flat JSON intopublic/r/.- stamp + check — substitutes
{REGISTRY_URL}and fails on any binary asset.
Because this is a Next.js app, the output is served statically: the docs site and the registry are one deployment.
Step 2 exists because shadcn build writes but never deletes. Renaming or removing an item leaves its old JSON
sitting in public/r/, still served and still installable, describing something that no longer exists — and the build
reports success. Wiping the directory first makes the published set always equal the catalog.
Only public/r/ is removed. public/theme-files/ and anything else in public/ is untouched, and public/r/ is
gitignored so a stale file cannot be committed either.
Renaming an item is still a breaking change for anyone who already installed it: their next add of the old URL gets a 404. Clearing the directory only stops you serving a stale copy — it cannot fix a consumer's pinned command. Prefer adding a new item and leaving the old one in place until nothing references it.
https://kiotapay-ui-staging.staging.kiotapay.com/r/registry.json # the catalog
https://kiotapay-ui-staging.staging.kiotapay.com/r/money-input.json # one itemThe origin comes from NEXT_PUBLIC_REGISTRY_URL. Every install command shown across these docs is generated from it, so moving hosts is a one-line environment change with no snippets to update.
Known limits
- No version pinning. A URL-hosted registry always serves latest. If you need pinning, publish through public GitHub refs or put a version in the URL template.
cssVarsare effectively install-time only. A fresh install always writes current values, but re-installing into a project that already has the variable does not reliably update it. Consumers adopting a palette change should edit their stylesheet directly.- Adopting a change is opt-in and destructive. Re-running
addprompts before touching an existing file and defaults to keeping it;--overwritereplaces it along with any local edits. See Updating for the workflow.