A Single Command to Install shadcn Components in a Deno Monorepo

shadcndenomonorepostorybookastronextjsvite

The shadcn/ui CLI assumes a standard Next.js or Vite project with a components.json, a tsconfig.json with path aliases, and a package.json. A Deno workspace has none of these. There's no @/lib/utils alias, no node_modules symlinks for workspace packages, and no single framework to target. If you want shadcn components shared across multiple apps in a Deno monorepo, you need to build your own installation pipeline.

We did. The result is a single Deno script behind deno task ui:add that handles everything the shadcn CLI would — fetching, import rewriting, export wiring — but mapped to the conventions of a Deno workspace. Here's how it works, and the workflow we built around it for Storybook verification and multi-framework integration.

The Problem with shadcn in Deno

shadcn/ui is not a package you install from npm. It's a registry of component source files that get copied into your project. The official CLI reads your components.json to know where to put files and how to rewrite imports. In a Deno workspace, that config doesn't exist. More importantly, the import conventions are completely different:

  • shadcn components use @/lib/utils — Deno workspaces use @workspace/ui/lib/utils
  • shadcn references sibling components with @/components/ui/button — we need @workspace/ui/components/button
  • There's no package.json to register exports — Deno uses deno.json with subpath exports

The gap isn't just path aliases. It's a different module resolution model. Every component file needs its imports transformed, and every new component needs to be registered in the package's export map so other workspace members can import it.

The ui:add Script

The script lives at scripts/ui-add.ts and runs as deno task ui:add <component...>. You can pass multiple components in a single invocation.

Registry fetch and dependency resolution. The script hits the shadcn registry API at ui.shadcn.com/r/styles/new-york/<name>.json. Each registry entry includes a registryDependencies field — for example, dialog depends on button. The script does a BFS traversal, queuing dependencies and deduplicating against what's already installed.

Import transformation. Every fetched source file passes through a series of regex replacements that map shadcn's internal import conventions to workspace paths:

@/lib/utils           → @workspace/ui/lib/utils
@/components/ui/foo   → @workspace/ui/components/foo
@/hooks/use-bar       → @workspace/ui/hooks/use-bar
@/registry/new-york/* → @workspace/ui/components/* (or hooks/*, lib/*)

This handles both the older @/components/ui/ pattern and the newer @/registry/new-york/ pattern that shadcn has been migrating to.

Export wiring. After writing the component file to packages/ui/src/components/, the script updates two things:

  1. packages/ui/deno.json — adds a subpath export like "./components/breadcrumb": "./src/components/breadcrumb.tsx"
  2. packages/ui/src/components/shadcn/index.ts — appends a barrel re-export with all the component's named exports

The script parses the source to extract export names (export { Foo, Bar } and export const Baz), so the barrel file always stays accurate.

Idempotency. Before fetching anything, the script checks packages/ui/deno.json exports for existing entries. Running deno task ui:add breadcrumb twice produces [skip] breadcrumb — already installed on the second run. No files are overwritten, no exports are duplicated.

Missing dependency warnings. shadcn components often depend on npm packages like @radix-ui/react-slot or lucide-react. The script checks each dependency against the root deno.json import map and prints warnings for anything missing, with the exact "dep": "npm:dep@^version" format you need to add.

Verifying in Storybook

Every component gets a Storybook story before it touches an app page. Our Storybook runs on Deno 2.6 with the @storybook/react-vite framework and a custom Vite plugin — denoWorkspaceResolver — that reads each workspace member's deno.json exports to resolve @workspace/* imports.

Stories follow CSF 3 format. For simple prop-driven components like Button, stories use args:

export const Default: Story = {
  args: { children: "Click me", variant: "default" },
};

For compositional components — things like breadcrumbs, forms, or accordions where you compose multiple sub-components — stories use render functions:

export const Default: Story = {
  render: () => (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="/">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Current Page</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  ),
};

The key detail is that stories import from @workspace/ui/components/breadcrumb — the exact same import path the apps use. If the story renders, the export wiring is correct and the component works in a Vite context. That eliminates an entire class of "works in isolation, breaks in the app" issues.

Consuming Components in Two Frameworks

The shared @workspace/ui package is framework-agnostic React. The framework-specific adaptation happens at the call site in each app. The same component, imported the same way, used slightly differently.

Astro renders React components as islands. The component needs client:load on the root element to hydrate, and links use plain href attributes since Astro handles navigation with full page loads:

<Breadcrumb client:load>
  <BreadcrumbList>
    <BreadcrumbItem>
      <BreadcrumbLink href="/articles">Articles</BreadcrumbLink>
    </BreadcrumbItem>
  </BreadcrumbList>
</Breadcrumb>

Next.js needs client-side navigation. shadcn's link components support Radix's asChild prop, which replaces the default <a> tag with whatever child you provide. Wrapping Next's <Link> component preserves prefetching and SPA-style transitions:

<BreadcrumbLink asChild>
  <Link href="/articles">Articles</Link>
</BreadcrumbLink>

This pattern — asChild for framework routers, plain href for static renderers — works for any shadcn component that renders links. NavigationMenu, DropdownMenu, and Tabs all support it.

The Workflow

The entire cycle for adding a shared component looks like this:

  1. deno task ui:add <name> — install, transform, wire exports
  2. Write a Storybook story, verify it renders with deno task dev:storybook
  3. Import into Astro with client:load, using href for links
  4. Import into Next.js with asChild + <Link> for navigation
  5. Remove any replaced imports (dead Button imports, old back-link markup)

No build step between the package and the apps. No version bumping. No publish cycle. Deno's workspace protocol resolves @workspace/ui/components/breadcrumb directly to the source file. Change the component, refresh the browser.

That's the advantage of building your own installation pipeline instead of fighting someone else's assumptions. The shadcn CLI was designed for single-app projects. Our script is designed for this specific workspace. It's 350 lines of Deno, it handles the five things we actually need, and it doesn't need a components.json to tell it what to do.