How to Use shadcn/ui and Radix UI Primitives in Next.js: A Complete Implementation Guide
The woosal1337/blog repository demonstrates how to build a lightweight, type-safe design system by wrapping Radix UI primitives with Tailwind CSS classes and a utility-based pattern in Next.js 14.
This article explores the practical implementation of shadcn/ui components within a Next.js 14 application using the App Router. By examining the woosal1337/blog codebase, you will learn how to combine Radix UI primitives with Tailwind CSS to create extensible, accessible UI components that work seamlessly across server and client boundaries.
Core Architecture of the Design System
The design system follows the shadcn/ui philosophy: unopinionated, composable components that you own and can customize. Unlike traditional component libraries, these primitives live directly in your source code.
The cn Utility for Class Management
Central to the architecture is the cn helper function defined in [lib/utils.tsx](https://github.com/woosal1337/blog/blob/main/lib/utils.tsx). This utility combines class-variance-authority (CVA) for variant management with tailwind-merge to resolve conflicting Tailwind classes safely.
// lib/utils.tsx
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Every UI component uses this function to merge base styles, variant classes, and user-provided overrides without class collisions.
Component Structure
Components reside in components/ui/ and follow a consistent pattern:
- Import the Radix primitive (e.g.,
@radix-ui/react-dialog) - Define TypeScript interfaces extending the primitive's props
- Apply Tailwind classes using the
cnutility - Export the component with forwardRef support
This approach ensures full type safety while maintaining the flexibility to override any style or behavior.
Implementing the Button Component
The Button component in [components/ui/button.tsx](https://github.com/woosal1337/blog/blob/main/components/ui/button.tsx) leverages @radix-ui/react-slot to allow polymorphic rendering. This means you can render the button as any element—an anchor tag, a Next.js Link, or a native button—while preserving all styling and behavior.
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function Navigation() {
return (
<Button asChild variant="outline" size="lg">
<Link href="/projects">View Projects</Link>
</Button>
);
}
The component uses CVA (class-variance-authority) to define style variants:
- Variants:
default,destructive,outline,secondary,ghost,link - Sizes:
default,sm,lg,icon
Each variant combination resolves to specific Tailwind classes for colors, borders, and hover states, all merged safely via the cn utility.
Building Accessible Dialogs
The Dialog implementation in [components/ui/dialog.tsx](https://github.com/woosal1337/blog/blob/main/components/ui/dialog.tsx) wraps @radix-ui/react-dialog to provide a fully accessible modal system with keyboard navigation and focus management.
The component exports several composable parts:
Dialog- The root providerDialogTrigger- The element that opens the dialogDialogContent- The modal container with overlayDialogHeader,DialogFooter- Layout containersDialogTitle,DialogDescription- Semantic heading elements
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export default function ProjectModal() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open Details</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Project Information</DialogTitle>
<DialogDescription>
Built with Next.js 14, shadcn/ui, and Radix primitives.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
Notice the "use client" directive. Dialog components require browser APIs for focus trapping and portal rendering, making them client components. The styling uses Tailwind's data attribute selectors like data-[state=open]:animate-in to handle enter/exit animations based on Radix's state machine.
Server and Client Component Boundaries
In the Next.js 14 App Router architecture used in this repository, component placement determines render behavior:
- Server Components: Static UI elements that don't require interaction can remain server-rendered for optimal performance
- Client Components: Interactive primitives like
Dialog,DropdownMenu, orCarouselmust include the"use client"directive at the top of the file
The Slot pattern from Radix is particularly valuable here. It allows you to pass a server-rendered Link component as a child to a client-rendered Button, maintaining SEO benefits while enabling client-side navigation.
Adding Custom Components
To extend the design system with new shadcn/ui-style components:
- Install the Radix primitive:
npm install @radix-ui/react-popover - Create the wrapper: Add
components/ui/popover.tsx - Apply the pattern: Import Radix parts, wrap with Tailwind classes using
cn, and export withforwardRef - Handle client/server: Add
"use client"if the component uses browser APIs
All components should reference the project's design tokens (e.g., bg-background, text-muted-foreground, border-border) defined in your Tailwind configuration to maintain consistency with the dark-only theme.
Summary
- The
cnutility inlib/utils.tsxcombinestailwind-mergeandclsxto handle conditional class merging safely across components. - Radix primitives provide unstyled, accessible behavior for complex interactions like dialogs and slots, which you style with Tailwind in
components/ui/. - The Slot pattern enables polymorphic components, allowing a Button to render as a Link while preserving type safety.
- Server/Client boundaries require the
"use client"directive for interactive components that use browser APIs, but static wrappers can remain server-rendered. - CVA manages component variants consistently, making it easy to extend styles while preventing class conflicts.
Frequently Asked Questions
What is the difference between shadcn/ui and traditional UI libraries?
Unlike traditional libraries where you install a package from npm, shadcn/ui provides code patterns that you copy directly into your project. You own the components in components/ui/, meaning you can customize every Tailwind class and behavior without fighting against a library's API constraints. The woosal1337/blog repository demonstrates this by maintaining full control over the Button and Dialog implementations while relying on Radix for complex accessibility logic.
Why does the Button component use @radix-ui/react-slot?
The Slot primitive allows the Button to accept an asChild prop that merges the Button's props and styling onto its immediate child element. This enables you to render the Button as a Next.js Link, an anchor tag, or any other element while maintaining consistent styling and behavior. Without Slot, you would need separate components for Button and ButtonLink, duplicating logic.
When should I use "use client" in shadcn/ui components?
Add the "use client" directive at the top of any component file that uses browser-only APIs, React hooks like useState or useEffect, or Radix primitives that require DOM access. In the woosal1337/blog repository, components like Dialog require this directive for focus management and portal rendering, while simple layout components can remain server-rendered for better performance.
How do I customize the color variants for shadcn/ui components?
Modify the CVA (class-variance-authority) configuration within each component file. For example, in components/ui/button.tsx, locate the buttonVariants object and add or modify entries in the variants object. Use the cn utility when applying classes to ensure your custom colors merge correctly with base styles and any Tailwind classes passed via the className prop.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →