Creating a Private Component Library for Your Team Using shadcn Registry
In the previous post we set up a public shadcn registry from scratch — a registry.json, components, a build step, and a deployed URL. That is the publisher side of the story.
This post covers the consumer side: how your teammates actually pull those private components into their projects using namespaces, how to lock the registry behind authentication so only your team can access it, and how to structure a real multi-team setup so everything stays organised as your library grows.
By the end you will have:
- A private registry that rejects unauthenticated requests
- A
components.jsonconfig that lets any teammate install from it with@your-namespace/component-name - A working Next.js API route that gates access with a bearer token
- Patterns for team-based and role-based access control
The Problem: Sharing Components Across Projects Without npm
Most teams eventually build the same things twice — a data table here, an auth form there, a sidebar in every project. The traditional fix is to publish an npm package. But that means versioning, a release pipeline, a registry, and convincing everyone to update their dependency whenever you change a button.
The shadcn registry sidesteps all of that. Instead of publishing compiled code, you distribute source files. Developers install the component's actual TypeScript directly into their project — owned, editable, no version lock-in. The only thing you need to manage is a JSON endpoint and a token.
Architecture Overview
┌─────────────────────────────────────────────┐
│ Your Registry Server │
│ │
│ GET /api/registry/:name │
│ ├── validate Bearer token │
│ ├── check team permissions │
│ └── return registry-item JSON │
│ │
│ public/r/ │
│ ├── button.json │
│ ├── data-table.json │
│ └── auth-form.json ← built by shadcn CLI │
└─────────────────────────────────────────────┘
▲ HTTPS + Bearer token
│
┌─────────────────────────────────────────────┐
│ Developer's Project │
│ │
│ components.json │
│ └── registries │
│ └── @acme → your registry URL │
│ │
│ .env.local │
│ └── ACME_REGISTRY_TOKEN=... │
│ │
│ $ npx shadcn@latest add @acme/data-table │
└─────────────────────────────────────────────┘
Part 1 — Securing the Registry (Publisher Side)
Your registry server needs to check every request for a valid token before returning component JSON. Here is how to do that in Next.js, which is what most shadcn projects use.
Create an authenticated API route
Instead of serving pre-built JSON from public/r/ directly (which anyone can fetch), proxy requests through an API route that validates credentials first.
import { NextRequest, NextResponse } from "next/server"
import { readFile } from "fs/promises"
import path from "path"
export async function GET(
request: NextRequest,
{ params }: { params: { name: string } }
) {
// ── 1. Extract token ──────────────────────────────────────────
const authHeader = request.headers.get("authorization")
const token = authHeader?.replace("Bearer ", "").trim()
if (!token) {
return NextResponse.json(
{
error: "Unauthorized",
message:
"Authentication required. Set ACME_REGISTRY_TOKEN in your .env.local file.",
},
{ status: 401 }
)
}
// ── 2. Validate token ─────────────────────────────────────────
if (!isValidToken(token)) {
return NextResponse.json(
{
error: "Unauthorized",
message: "Invalid token. Request access at internal.acme.com/registry.",
},
{ status: 401 }
)
}
// ── 3. Check component-level permissions ──────────────────────
const { name } = params
if (!hasAccessToComponent(token, name)) {
return NextResponse.json(
{
error: "Forbidden",
message: `Component '${name}' is restricted. Contact your team lead.`,
},
{ status: 403 }
)
}
// ── 4. Serve the pre-built registry JSON ──────────────────────
try {
const filePath = path.join(process.cwd(), "public", "r", `${name}.json`)
const contents = await readFile(filePath, "utf-8")
const json = JSON.parse(contents)
return NextResponse.json(json, {
headers: {
// Tell the shadcn CLI this is a valid registry item
"Content-Type": "application/vnd.shadcn.v1+json",
// Prevent CDN caching of private content
"Cache-Control": "no-store",
},
})
} catch {
return NextResponse.json(
{ error: "Not Found", message: `Component '${name}' does not exist.` },
{ status: 404 }
)
}
}
// ── Token validation ───────────────────────────────────────────────────────
function isValidToken(token: string): boolean {
// Simple shared-secret approach for small teams.
// Replace with a database lookup or JWT validation for larger setups.
const validTokens = (process.env.VALID_REGISTRY_TOKENS ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
return validTokens.includes(token)
}
// ── Component-level access control ────────────────────────────────────────
const RESTRICTED_COMPONENTS = ["billing-dashboard", "admin-panel"]
function hasAccessToComponent(token: string, componentName: string): boolean {
// If the component is unrestricted, allow any valid token.
if (!RESTRICTED_COMPONENTS.includes(componentName)) {
return true
}
// Restricted components require an admin token.
const adminTokens = (process.env.ADMIN_REGISTRY_TOKENS ?? "")
.split(",")
.map((t) => t.trim())
return adminTokens.includes(token)
}Configure your environment variables
# Comma-separated list of valid tokens for regular access
VALID_REGISTRY_TOKENS=tok_abc123,tok_def456,tok_ghi789
# Tokens that also unlock restricted components
ADMIN_REGISTRY_TOKENS=tok_abc123Never commit real tokens. Add
.env.localto.gitignoreand distribute tokens to teammates through a password manager or secrets manager (1Password, Doppler, AWS Secrets Manager, etc.).
Update the registry URL in registry.json
Now that requests go through your API route instead of public/r/ directly, update your registry.json homepage so teammates know the correct install base:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "acme-ui",
"homepage": "https://registry.acme.com",
"items": [
{
"name": "data-table",
"type": "registry:block",
"title": "Data Table",
"description": "Feature-rich data table with sorting, filtering, and pagination.",
"registryDependencies": ["button", "input", "select"],
"dependencies": ["@tanstack/react-table"],
"files": [
{
"path": "registry/new-york/data-table/data-table.tsx",
"type": "registry:component"
},
{
"path": "registry/new-york/data-table/use-data-table.ts",
"type": "registry:hook"
}
]
}
]
}Run the build to regenerate public/r/:
pnpm registry:buildPart 2 — Consuming the Registry (Developer Side)
Every developer on your team needs two things: an entry in their components.json that points to your registry, and a token in their .env.local.
Step 1 — Initialise shadcn in the consumer project
If the project does not already have shadcn set up:
npx shadcn@latest initThis creates a components.json at the project root.
Step 2 — Add the private registry to components.json
Open components.json and add a registries field:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"utils": "@/lib/utils"
},
"registries": {
"@acme": {
"url": "https://registry.acme.com/api/registry/{name}",
"headers": {
"Authorization": "Bearer ${ACME_REGISTRY_TOKEN}"
}
}
}
}The {name} placeholder is replaced with the component name at install time. So @acme/data-table fetches https://registry.acme.com/api/registry/data-table.
The ${ACME_REGISTRY_TOKEN} syntax is automatically expanded from process.env — it is never logged or stored by the CLI.
Step 3 — Add the token to .env.local
ACME_REGISTRY_TOKEN=tok_def456Each teammate gets their own token. This way you can revoke individual access without rotating the shared secret for everyone.
Step 4 — Install components
With the registry configured, installing any component is a single command:
# Install one component
npx shadcn@latest add @acme/data-table
# Install multiple at once
npx shadcn@latest add @acme/data-table @acme/auth-form @acme/sidebar
# pnpm
pnpm dlx shadcn@latest add @acme/data-table
# bun
bunx shadcn@latest add @acme/data-tableThe CLI automatically resolves and installs all registryDependencies and dependencies declared in the registry item — so if data-table depends on button, input, and @tanstack/react-table, all of those are installed too.
Inspect a component before installing
npx shadcn@latest view @acme/data-tableThis prints the full registry item JSON to the terminal without writing any files — useful for reviewing what a component does before committing to the install.
Part 3 — Multi-Registry and Multi-Team Setups
As your library grows, you might want separate registries for different teams, purposes, or stability levels. The registries field in components.json accepts any number of namespaces.
Organise by team
{
"registries": {
"@acme-design": {
"url": "https://registry.acme.com/api/registry/{name}",
"headers": { "Authorization": "Bearer ${DESIGN_REGISTRY_TOKEN}" }
},
"@acme-platform": {
"url": "https://platform.acme.com/api/registry/{name}",
"headers": { "Authorization": "Bearer ${PLATFORM_REGISTRY_TOKEN}" }
},
"@acme-ai": {
"url": "https://ai.acme.com/api/registry/{name}",
"headers": { "Authorization": "Bearer ${AI_REGISTRY_TOKEN}" }
}
}
}DESIGN_REGISTRY_TOKEN=tok_design_abc
PLATFORM_REGISTRY_TOKEN=tok_platform_def
AI_REGISTRY_TOKEN=tok_ai_ghiOrganise by stability
{
"registries": {
"@acme": "https://registry.acme.com/api/registry/{name}",
"@acme-beta": {
"url": "https://registry.acme.com/api/beta/{name}",
"headers": { "Authorization": "Bearer ${ACME_BETA_TOKEN}" }
},
"@acme-experimental": {
"url": "https://registry.acme.com/api/experimental/{name}",
"headers": { "Authorization": "Bearer ${ACME_EXPERIMENTAL_TOKEN}" }
}
}
}Mix public and private
You can freely mix the official shadcn registry with your own private one. A registry item in your private registry can declare a registryDependency on a public shadcn component:
{
"items": [
{
"name": "auth-form",
"registryDependencies": [
"button", ← resolved from the official shadcn registry
"input", ← resolved from the official shadcn registry
"@acme/token-input" ← resolved from your private registry
]
}
]
}The CLI resolves each dependency from the correct registry automatically.
Part 4 — Advanced Authentication Patterns
Team-based access: one server, multiple token tiers
Instead of a flat list of valid tokens, map tokens to teams and derive permissions from there:
// Map of token → team
const TOKEN_TEAM_MAP: Record<string, string> = {
tok_abc123: "design",
tok_def456: "engineering",
tok_ghi789: "marketing",
}
// Map of component → teams that can access it
const COMPONENT_ACCESS: Record<string, string[]> = {
"billing-dashboard": ["engineering"],
"admin-panel": ["engineering"],
"brand-kit": ["design", "marketing"],
// Components not listed here are available to any valid token
}
function getTeamFromToken(token: string): string | null {
return TOKEN_TEAM_MAP[token] ?? null
}
function hasAccessToComponent(token: string, componentName: string): boolean {
const team = getTeamFromToken(token)
if (!team) return false
const allowedTeams = COMPONENT_ACCESS[componentName]
if (!allowedTeams) return true // no restriction — any valid team
return allowedTeams.includes(team)
}Expiring tokens for tighter security
import crypto from "crypto"
interface TokenRecord {
token: string
teamId: string
expiresAt: Date
}
// In practice, store these in a database, not in memory
const activeTokens = new Map<string, TokenRecord>()
export function generateToken(teamId: string, ttlDays = 30): string {
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
activeTokens.set(token, { token, teamId, expiresAt })
return token
}
export function validateToken(token: string): TokenRecord | null {
const record = activeTokens.get(token)
if (!record) return null
if (new Date() > record.expiresAt) {
activeTokens.delete(token) // clean up expired token
return null
}
return record
}Then in the route:
import { validateToken } from "@/lib/registry-tokens"
const record = validateToken(token)
if (!record) {
return NextResponse.json(
{
error: "Unauthorized",
message: "Token is invalid or has expired. Request a new token at internal.acme.com/tokens.",
},
{ status: 401 }
)
}Rate limiting to prevent abuse
Install @upstash/ratelimit (or any rate-limiter) to protect the endpoint:
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "15 m"), // 100 requests per 15 minutes per identifier
})
export async function GET(request: NextRequest, { params }: { params: { name: string } }) {
const token = request.headers.get("authorization")?.replace("Bearer ", "").trim() ?? ""
const { success } = await ratelimit.limit(token || request.ip ?? "anonymous")
if (!success) {
return NextResponse.json(
{ error: "Too Many Requests", message: "Slow down. Try again in a few minutes." },
{ status: 429 }
)
}
// ... rest of your auth and serving logic
}Access logging
Know who installed what and when:
interface AccessLogEntry {
timestamp: Date
token: string
teamId: string
component: string
ip: string
userAgent: string
}
export async function logAccess(
request: NextRequest,
teamId: string,
component: string
): Promise<void> {
const entry: AccessLogEntry = {
timestamp: new Date(),
token: "redacted", // never log the actual token
teamId,
component,
ip: request.ip ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
}
// Write to your logging service — Axiom, Datadog, a database, etc.
console.log(JSON.stringify(entry))
}Part 5 — Onboarding a New Teammate
Here is a copy-paste ready checklist to give to every new team member joining your component library.
For the registry admin (you)
- Generate a token for the new person:
crypto.randomBytes(32).toString("hex") - Add it to
VALID_REGISTRY_TOKENSon the registry server - Share the token securely via 1Password / Doppler / your secrets tool of choice
- Optionally, add it to the access log so you can track usage
For the new developer
Step 1 — Initialise shadcn if not done already:
npx shadcn@latest initStep 2 — Add the registry to components.json:
{
"registries": {
"@acme": {
"url": "https://registry.acme.com/api/registry/{name}",
"headers": {
"Authorization": "Bearer ${ACME_REGISTRY_TOKEN}"
}
}
}
}Step 3 — Add the token to .env.local:
ACME_REGISTRY_TOKEN=<token shared by admin>Step 4 — Install your first component to verify everything works:
npx shadcn@latest add @acme/hello-worldStep 5 — Browse what's available:
# List all available registry items
npx shadcn@latest view @acmeThat is the full onboarding. Five steps, under five minutes.
Part 6 — Security Best Practices Checklist
- Always use HTTPS. Never serve registry content over
http://. Tokens in transit over plain HTTP are trivially intercepted. - One token per developer. Shared tokens make revocation painful. Individual tokens mean you can cut one person's access without affecting everyone else.
- Store tokens in
.env.local, never in.env..envis often committed..env.localis excluded from git by default in Next.js projects. - Never commit tokens to version control. Use a secrets manager to distribute them.
- Rotate tokens on a schedule. Monthly or quarterly rotation limits exposure if a token leaks.
- Return helpful error messages. When auth fails, tell the developer exactly what to do — a link to the token request page is better than a bare 401.
- Add rate limiting. Even authenticated endpoints should be rate-limited. A tight inner loop or a bug in a CI script should not be able to DDOS your registry.
- Log all access. Knowing who fetched which component when is essential for auditing and debugging.
- Validate all output against the schema. The shadcn CLI validates registry items against the schema before installing. Make sure your generated JSON always passes — run
shadcn buildand review the output before deploying.
Part 7 — Keeping the Library Up to Date
The registry is just files — updating a component is as simple as editing the source, rebuilding, and redeploying.
Workflow for updating an existing component
# 1. Edit the component source
code registry/new-york/data-table/data-table.tsx
# 2. Rebuild to regenerate public/r/data-table.json
pnpm registry:build
# 3. Deploy (Vercel auto-deploys on push)
git add .
git commit -m "feat(data-table): add column pinning"
git pushNotifying teammates
Because registry items are installed as source files, teammates are not automatically updated when you change a component. The convention is to communicate changes through:
- A changelog in your internal docs
- A Slack message in a
#design-systemchannel - A
docsfield on the registry item itself (shown in the terminal on install)
{
"name": "data-table",
"docs": "Updated 2026-05-28: Added column pinning support. Re-install to get the latest version: npx shadcn@latest add @acme/data-table"
}When a developer re-runs shadcn add, the CLI will ask before overwriting existing files, so they are always in control of when they take an update.
Summary
| Concern | Where it lives | What to do |
|---|---|---|
| Component source | registry/new-york/[name]/ | Edit, then run pnpm registry:build |
| Registry manifest | registry.json | Add items, rebuild |
| Auth logic | app/api/registry/[name]/route.ts | Validate token, check permissions |
| Valid tokens | .env.local (server) | Add/remove per developer |
| Registry config | components.json (consumer) | Add registries field |
| Developer token | .env.local (consumer) | Set YOUR_REGISTRY_TOKEN=... |
| Installing | Consumer terminal | npx shadcn@latest add @namespace/name |