Read OSS

shadcn/ui Architecture: How a Component Distribution System Works

Intermediate

Prerequisites

  • Basic TypeScript knowledge
  • Familiarity with npm/pnpm package management
  • General understanding of component libraries in React

shadcn/ui Architecture: How a Component Distribution System Works

Most component libraries ship compiled JavaScript through npm. You install a package, import components, and hope the library's opinions about styling, bundling, and API design align with yours. shadcn/ui rejects this model entirely. Instead, it distributes source code — copying component files directly into your project so you own every line. This article explores the architecture that makes that possible: a registry protocol backed by a CLI, a build pipeline that generates static JSON APIs, and a package design that serves humans, programs, and AI assistants from a single codebase.

The "copypaste Not Package" Philosophy

The core insight behind shadcn/ui is that UI components are not infrastructure. They're application code — things you'll inevitably need to modify for your specific product. By distributing source rather than compiled packages, shadcn/ui eliminates the version-lock problem: there's no node_modules/shadcn-button to worry about keeping in sync.

This philosophy creates a unique architectural challenge. The system needs to:

  1. Author components once in a canonical form
  2. Transform those components to match each project's configuration (aliases, icon libraries, CSS frameworks)
  3. Distribute the transformed source via an HTTP API
  4. Install the files into the correct directory structure

The CLI entry point at packages/shadcn/src/index.ts registers 11 commands that orchestrate this pipeline:

flowchart LR
    A[shadcn init] --> B[Configure Project]
    C[shadcn add] --> D[Fetch from Registry]
    D --> E[Transform Source]
    E --> F[Write Files]
    G[shadcn build] --> H[Generate JSON API]

The CLI also re-exports the registry API for programmatic use on line 48 — a small detail with significant implications we'll explore shortly.

Monorepo Layout and Package Boundaries

The repository is a pnpm workspace with Turborepo orchestrating builds. The root package.json declares the workspace structure:

Directory Purpose
apps/v4 Next.js documentation site + registry source (dual role)
packages/shadcn The CLI package published to npm as shadcn
packages/tests Integration test suite
templates/* 10 starter templates (next-app, next-monorepo, vite-app, vite-monorepo, react-router-app, react-router-monorepo, start-app, start-monorepo, astro-app, astro-monorepo)
graph TD
    Root["ui (pnpm workspace)"]
    Root --> Apps["apps/"]
    Root --> Packages["packages/"]
    Root --> Templates["templates/"]
    Apps --> V4["v4 - Next.js docs + registry source"]
    Packages --> CLI["shadcn - CLI + registry API"]
    Packages --> Tests["tests - Integration tests"]
    Templates --> Next["next-app / next-monorepo"]
    Templates --> Vite["vite-app / vite-monorepo"]
    Templates --> RR["react-router-app / react-router-monorepo"]
    Templates --> Start["start-app / start-monorepo"]
    Templates --> Astro["astro-app / astro-monorepo"]

The turbo.json pipeline configuration uses dependsOn: ["^build"] for the build task, ensuring the CLI package compiles before anything that depends on it. Notice how environment variables like REGISTRY_URL and COMPONENTS_REGISTRY_URL are explicitly passed through — the registry URL is configurable at build time, which is how the local development server at localhost:4000 substitutes for the production ui.shadcn.com endpoint.

Tip: When developing locally, pnpm shadcn:dev starts the CLI in watch mode while pnpm v4:dev starts the docs site. The start:dev script in the CLI's package.json sets REGISTRY_URL=http://localhost:4000/r so the CLI fetches from your local registry.

The CLI as a Multi-Surface Package

The shadcn npm package is not just a CLI. It exposes 7 sub-path exports defined in packages/shadcn/package.json, plus a CSS file:

Export Purpose
shadcn (root) CLI entry point + re-exported registry API
shadcn/registry Programmatic registry API for library consumers
shadcn/schema Zod schemas for config and registry items
shadcn/mcp MCP server for AI coding assistants
shadcn/utils Style transforms and utilities
shadcn/icons Icon library definitions
shadcn/preset Preset configuration system
shadcn/tailwind.css Base Tailwind CSS

The tsup.config.ts produces 7 ESM entry points with tree-shaking enabled. Two dependencies — @antfu/ni and tinyexec — are explicitly bundled via noExternal to avoid resolution failures when users run the CLI through npx (which creates temporary installs with incomplete dependency trees).

classDiagram
    class shadcn {
        +CLI commands
        +registry API re-export
    }
    class registry {
        +getRegistryItems()
        +searchRegistries()
        +resolveRegistryItems()
    }
    class schema {
        +rawConfigSchema
        +registryItemSchema
        +registryItemTypeSchema
    }
    class mcp {
        +MCP Server
        +7 tools
    }
    class utils {
        +transformStyle()
        +createStyleMap()
    }
    shadcn --> registry
    shadcn --> schema
    mcp --> registry
    mcp --> schema
    utils --> schema

This multi-surface design means the same codebase serves three audiences: developers using the CLI, programs importing shadcn/registry, and AI assistants connecting via the MCP server. We'll explore each surface in detail in later articles.

Registry Item Types and the Type Hierarchy

At the heart of shadcn/ui's data model is the registry item — a JSON document describing a component, hook, utility, or configuration. The registryItemTypeSchema defines 14 types:

classDiagram
    class RegistryItem {
        +name: string
        +type: RegistryItemType
        +files: RegistryItemFile[]
        +dependencies: string[]
        +registryDependencies: string[]
        +cssVars: CssVars
        +css: CssProperties
    }
    class UI["registry:ui"]
    class Lib["registry:lib"]
    class Hook["registry:hook"]
    class Block["registry:block"]
    class Style["registry:style"]
    class Theme["registry:theme"]
    class Base["registry:base"]
    class Font["registry:font"]
    class Page["registry:page"]
    class File["registry:file"]
    RegistryItem <|-- UI
    RegistryItem <|-- Lib
    RegistryItem <|-- Hook
    RegistryItem <|-- Block
    RegistryItem <|-- Style
    RegistryItem <|-- Theme
    RegistryItem <|-- Base
    RegistryItem <|-- Font
    RegistryItem <|-- Page
    RegistryItem <|-- File

The schema uses Zod's discriminatedUnion on the type field. Two types get special treatment: registry:base items carry a config field (a partial rawConfigSchema), and registry:font items carry a font field with family, provider, and CSS variable metadata. This is defined in registryItemSchema.

The type determines where files land in your project. registry:uicomponents/ui/, registry:hookhooks/, registry:liblib/. This mapping happens during file writing, which we'll cover in Article 3.

The apps/v4 Dual Role: Documentation and Registry Source

The apps/v4 directory is a Next.js application that serves two purposes. At runtime, it powers ui.shadcn.com — the documentation site with interactive component previews. At build time, it's the source for the static JSON registry served at ui.shadcn.com/r/.

flowchart TD
    A["apps/v4/registry/bases/radix/"] --> B["build-registry.mts"]
    C["apps/v4/registry/bases/base/"] --> B
    D["apps/v4/registry/styles/*.css"] --> B
    B --> E["Style Transforms"]
    E --> F["shadcn build command"]
    F --> G["apps/v4/public/r/*.json"]
    G --> H["ui.shadcn.com/r/"]

Component authors write source in registry/bases/radix/ and registry/bases/base/. The build script at apps/v4/scripts/build-registry.mts combines each base with each style, applies transformations, and outputs static JSON files to public/r/. These JSON files contain the component source code, dependencies, CSS variables, and metadata — everything the CLI needs to install a component without any server-side processing.

Configuration with components.json

Every project using shadcn/ui has a components.json at its root. The schema is defined by rawConfigSchema:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "radix-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "rtl": false,
  "menuAccent": "subtle",
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "registries": {
    "@acme": "https://acme.com/r/{name}.json"
  }
}

The registries field is where things get interesting. It maps namespace prefixes (like @acme) to URL templates with {name} and {style} placeholders. When you run shadcn add @acme/button, the CLI resolves the URL, fetches the JSON, and installs the component — even from private registries with authentication headers. Built-in registries like @shadcn are defined in constants.ts and cannot be overridden.

At runtime, get-config.ts loads the config using cosmiconfig, resolves TypeScript path aliases via tsconfig-paths, and merges built-in registries with user registries. The resolved config — with absolute filesystem paths — is what the rest of the system operates on.

Tip: The getConfig function (line 31) defaults the icon library based on the style name: new-york styles get radix icons, everything else gets lucide. This legacy behavior is preserved for backward compatibility.

What's Next

This article established the architectural foundation: a monorepo with clear boundaries, a CLI that serves three audiences, and a registry protocol that replaces traditional npm distribution. In Part 2, we'll dive deep into that registry protocol — how namespace parsing, URL construction, HTTP fetching with caching, and Kahn's topological sort algorithm work together to resolve a component's complete dependency tree.