Read OSS

Building the Registry: From Authored Components to Static JSON API

Advanced

Prerequisites

  • Article 3: code-transformation-pipeline
  • Understanding of PostCSS and CSS custom properties
  • Familiarity with build pipelines and worker parallelism

Building the Registry: From Authored Components to Static JSON API

In Articles 2 and 3, we followed a component from the registry to your project. Now we reverse the flow: how do authored components become the static JSON files that the CLI fetches? The answer is a 9-step build pipeline in apps/v4/scripts/build-registry.mts that combines two component libraries with six visual styles, applies parallel transforms, and outputs hundreds of JSON files to public/r/.

The Dual-Base Architecture: Radix vs Base UI

shadcn/ui supports two headless component libraries: Radix UI and Base UI. Each has a complete registry of components authored separately in apps/v4/registry/bases/radix/ and apps/v4/registry/bases/base/. Both registries use the same cn-* class naming convention, which is the key architectural insight — the same style definitions work with either base.

classDiagram
    class RadixRegistry {
        +accordion.tsx (uses @radix-ui/react-accordion)
        +button.tsx
        +dialog.tsx
        +...
    }
    class BaseRegistry {
        +accordion.tsx (uses @base-ui-components/react)
        +button.tsx
        +dialog.tsx
        +...
    }
    class StyleNova["style-nova.css"]
    class StyleVega["style-vega.css"]
    
    StyleNova --> RadixRegistry : cn-* classes
    StyleNova --> BaseRegistry : cn-* classes
    StyleVega --> RadixRegistry : cn-* classes
    StyleVega --> BaseRegistry : cn-* classes

This means a Button component in Radix base and a Button component in Base UI base might have completely different implementations (different headless primitives, different prop interfaces), but they share cn-button, cn-button-variant-default, etc. When the build pipeline applies a style, it resolves those abstract classes to concrete Tailwind utilities — and the result works regardless of which headless library you chose.

Style Definitions and the cn-* Abstraction Layer

Six style CSS files live in apps/v4/registry/styles/:

File Style
style-nova.css Nova — Lucide / Geist
style-vega.css Vega — Lucide / Inter
style-maia.css Maia — Hugeicons / Figtree
style-lyra.css Lyra — Phosphor / JetBrains Mono
style-mira.css Mira — Hugeicons / Inter
style-luma.css Luma — Lucide / Inter

Each file wraps all class definitions in a parent selector (e.g., .style-nova) and uses @apply directives. Here's a simplified excerpt from style-nova.css (the actual file contains many more utility classes per selector):

.style-nova {
  .cn-accordion-item {
    @apply not-last:border-b;
  }
  .cn-accordion-trigger {
    @apply focus-visible:ring-ring/50 rounded-lg py-2.5 text-sm font-medium hover:underline;
  }
}

As we saw in Article 3, the createStyleMap function parses this CSS to build a cn-* → Tailwind mapping. The abstraction layer means visual design is completely decoupled from component logic. A designer can create an entirely new style by writing a CSS file that maps every cn-* class to different utilities — no component code changes needed.

Tip: To create a custom style, copy an existing style-*.css file and modify the @apply values. The cn-* class names are your contract — every component expects them to exist in the style map.

The Cartesian Product: Bases × Styles

The build script computes all style combinations as a cartesian product defined at line 58-65:

const STYLE_COMBINATIONS = Array.from(BASES).flatMap((base) =>
  STYLES.map((style) => ({
    base,
    style,
    name: `${base.name}-${style.name}`,
    title: `${base.title} ${style.title}`,
  }))
)

With 2 bases and 6 styles, this produces 12 combinations: radix-nova, radix-vega, radix-maia, radix-lyra, radix-mira, radix-luma, base-nova, base-vega, etc.

graph LR
    subgraph Bases
        R[radix]
        B[base]
    end
    subgraph Styles
        N[nova]
        V[vega]
        M[maia]
        L[lyra]
        Mi[mira]
        Lu[luma]
    end
    subgraph "Output (12 combinations)"
        RN[radix-nova]
        RV[radix-vega]
        BN[base-nova]
        BV[base-vega]
        More[...]
    end
    R --> RN
    R --> RV
    R --> More
    B --> BN
    B --> BV
    B --> More
    N --> RN
    N --> BN
    V --> RV
    V --> BV

Each combination gets its own directory under public/r/styles/. When a user's components.json has style: "radix-nova", the CLI fetches from https://ui.shadcn.com/r/styles/radix-nova/button.json.

The 9-Step Build Pipeline

The main pipeline at lines 309-368 executes these steps:

flowchart TD
    S1["1. Build bases/__index__.tsx"] --> S2["2. Build base registries"]
    S2 --> S3["3. Build registry/__index__.tsx"]
    S3 --> S4["4. Build examples/__index__.tsx"]
    S4 --> S5["5. Build styled JSON + CLI export per style"]
    S5 --> S6["6. Build blocks index"]
    S6 --> S7["7. Build config, index, registries, colors"]
    S7 --> S8["8. Copy UI to styles + build RTL"]
    S8 --> S9["9. Clean up temporaries"]

Step 5 is where the heavy lifting happens. For each style combination, the pipeline:

  1. Reads each component's source from the base registry
  2. Applies the style transform via transformStyle (which uses createStyleMap and transformStyleMap from Article 3)
  3. Rewrites internal import paths from @/registry/bases/<base>/ to @/registry/<style>/
  4. Caches the result using a content-hash manifest to skip unchanged files
  5. Writes a temporary registry-<style>.json manifest
  6. Invokes the shadcn build CLI command to produce the final JSON files

The concurrency settings at lines 67-75 are tuned to available CPU cores:

const CPU_COUNT = availableParallelism()
const STYLE_BUILD_CONCURRENCY = Math.max(1, Math.min(CPU_COUNT, 4))
const FILE_BUILD_CONCURRENCY = Math.max(4, Math.min(CPU_COUNT, 8))
const CLI_BUILD_CONCURRENCY = Math.max(1, Math.min(Math.floor(CPU_COUNT / 2), 4))

A generic runWithConcurrency function (lines 285-307) implements a worker pool pattern: it spawns limit concurrent workers that pull from a shared index counter. This avoids spawning all tasks at once, which could overwhelm the filesystem or process limits.

The transform cache deserves mention. Each styled file is cached to node_modules/.cache/build-registry/transforms/ with a SHA-256 content hash. The manifest tracks style:filepath → hash mappings. On subsequent builds, only files whose source or style CSS changed need re-transformation. This makes incremental builds fast — seconds instead of minutes.

The CLI build Command

The shadcn build command at packages/shadcn/src/commands/build.ts reads a registry.json manifest, reads each referenced file's content, validates it against registryItemSchema, and writes individual JSON files to the output directory.

sequenceDiagram
    participant Script as build-registry.mts
    participant CLI as shadcn build
    participant FS as File System

    Script->>FS: Write registry-radix-nova.json
    Script->>CLI: shadcn build registry-radix-nova.json -o public/r/styles/radix-nova
    CLI->>FS: Read registry-radix-nova.json
    CLI->>CLI: Validate with registrySchema
    loop For each item
        CLI->>FS: Read source file content
        CLI->>CLI: Validate with registryItemSchema
        CLI->>FS: Write button.json, card.json, etc.
    end
    CLI->>FS: Copy registry.json to output

Each output JSON file contains the full registry item: name, type, dependencies, file contents, CSS variables, and metadata. The CLI adds a $schema field pointing to https://ui.shadcn.com/schema/registry-item.json for IDE validation support.

This command is also available to third-party registry authors. You write a registry.json manifest, point files at your component source, and run shadcn build to produce the same static JSON format that the shadcn registry uses. No special infrastructure needed — the output can be hosted on any static file server.

Tip: RTL styles are only generated for base-nova and radix-nova (line 133-135). If you need RTL support, choose the Nova style as your base. The shouldGenerateRtlStyles function is the gate.

What's Next

We've traced the complete lifecycle from authored source to static JSON API. In Part 5, we'll look at the other end — how the init command scaffolds new projects from templates, detects frameworks, applies presets, and handles the special case of monorepo workspace routing.