Monorepo UI Sharing with Next.js 16 and shadcn/ui (Bun + Turborepo)

A shared UI library setup for a Bun and Turborepo monorepo using Next.js 16, React 19, Tailwind CSS v4, and shadcn/ui.
Sharing UI components across applications in a monorepo sounds trivial until you collide with the modern frontend stack: ESM-only packages, Tailwind v4’s single-file CSS engine, Next.js 16’s strict module graph, and the ergonomics of shadcn/ui. This guide shows how to get all of it working cleanly inside a Bun/Turborepo monorepo without running shadcn init inside each app.
The setup is based on a real-world integration: a shared UI library @workspace/ui consumed by a Next.js 16 dashboard app.
The goal is simple: one Tailwind config, one UI library, many apps, zero duplication.
Architecture Overview
To ground everything, here’s the stack:
-
Next.js 16 + React 19
-
Tailwind CSS v4 (the new single-file engine)
-
shadcn/ui components (Radix UI + tailwind-variants + tailwind-merge)
-
Bun 1.3+, Turborepo, Biome
-
packages/ui → your shared component library
-
apps/dashboard → your Next.js consumer app
The key idea is: The UI package owns Tailwind v4, PostCSS, tokens, global styles and component primitives. The apps only consume. No more duplicated tailwind configs across apps.
Workspace Wiring
The monorepo uses classic npm-style workspaces, which Bun fully supports.
Root package.json:
{
"workspaces": ["packages/*", "apps/*"]
}
Then the dasboard depends on the library using workspace protocol:
{
"dependencies": {
"@workspace/ui": "workspace:*",
},
"name": "@workspace/dashboard",
}
This lets Next.js transpile and bundle the shared package as if it were a local dependency.
Building the Shared UI Package
The UI package is the “source of truth” for: Components, Styles, Tailwind/PostCSS config, Tokens, shadcn/ui generator aliases
packages/ui/package.json:
{
"name": "@workspace/ui",
"type": "module",
"exports": {
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs"
}
}
Using exports ensures clean import paths:
import { Button } from "@workspace/ui/components/button";
import "@workspace/ui/globals.css";
Everything is strictly ESM.
Tailwind v4 no longer uses config files by default. Instead, directives inside a CSS file drive the entire engine. Your unified stylesheet:
packages/ui/src/styles/globals.css:
@import "tailwindcss";
@import "tw-animate-css";
/* Tell Tailwind what to scan */
@source "../../../apps/**/*.{ts,tsx}";
@source "../../../components/**/*.{ts,tsx}";
@source "../**/*.{ts,tsx}";
This file is exported so each app can import it exactly once.
packages/ui/postcss.config.mjs:
export default {
plugins: {
"@tailwindcss/postcss": {}
}
}
This uses the official Tailwind v4 PostCSS plugin—documented at ui.shadcn.com/monorepo.
You export it so the apps can plug into the same config.
Right now the consuming apps still need @tailwindcss/postcss installed on their side as well.
The magic file packages/ui/components.json :
{
"$schema": "https://ui.shadcn.com/schema.json",
"aliases": {
"components": "@workspace/ui/components",
"hooks": "@workspace/ui/hooks",
"lib": "@workspace/ui/lib",
"ui": "@workspace/ui/components",
"utils": "@workspace/ui/lib/utils"
},
"iconLibrary": "lucide",
"rsc": true,
"style": "new-york",
"tailwind": {
"baseColor": "neutral",
"config": "",
"css": "src/styles/globals.css",
"cssVariables": true
},
"tsx": true
}
utils.ts usually exposes the cn helper (class merging):
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...args) {
return twMerge(clsx(args));
}
Wiring the Next.js 16 App
This is where people usually trip. Next.js 16 is strict about external packages. Here’s how to make it smooth.
apps/dashboard/next.config.ts:
const nextConfig = {
transpilePackages: ["@workspace/ui"]
};
export default nextConfig;
This is required because:
-
The UI package ships TS/ESM
-
It contains CSS imports
-
It uses Tailwind v4 scanning rules
Without this you get bizarre module errors.
apps/dashboard/postcss.config.mjs:
export { default } from "@workspace/ui/postcss.config";
apps/dashboard/src/app/layout.tsx:
import "@workspace/ui/globals.css";
Since the UI package owns all tokens and layers, the app becomes style-declarative rather than style-authoritative.
apps/dashboard/tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
}
The second alias is critical:
-
You get live types during development
-
No need to publish a build of
@workspace/uijust to get correct types
The app also has a components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"ui": "@workspace/ui/components",
"utils": "@workspace/ui/lib/utils"
},
"iconLibrary": "lucide",
"rsc": true,
"style": "new-york",
"tailwind": {
"baseColor": "neutral",
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"cssVariables": true
},
"tsx": true
}
So if you run:
cd apps/dashboard
bunx --bun shadcn@latest add button
The generated component references your shared library correctly and in your page:
import { Button } from "@workspace/ui/components/button";
export default function Page() {
return <Button>Click me</Button>;
}
If you’ve wired everything properly, this Just Works.
Final words
Putting the reasoning together:
-
The dashboard imports one CSS file,
@workspace/ui/globals.css. -
Tailwind v4 scans both
apps/**/*andpackages/ui/**/*thanks to the@sourcedirectives. -
shadcn/ui knows to generate imports that point directly to
@workspace/ui. -
Next.js transpiles the UI package so CSS, TS, and ESM are correctly handled.
-
There is only one Tailwind/PostCSS config in the entire monorepo. Every app consumes it. No divergence.
Happy coding !