How to Add a Custom shadcn Component to Your CMS
Why customizing the admin is a 10-minute job, not a plugin-development project
Try adding a custom field type to Strapi. You'll be building a plugin, registering a field, and writing two React components by lunch. Try doing the same thing to Sanity — now you're in Studio plugin land. Payload? Custom field types via their plugin API.
UnfoldCMS treats this differently because the admin isn't a vendor product. It's your React codebase with shadcn/ui components living in components/ui/. Adding a custom component is a 10-minute job, no plugin system involved.
TL;DR: Because UnfoldCMS ships shadcn/ui as source files (not as an npm dependency), you customize the admin the same way you'd customize any React project — edit the file. This post walks through three real examples: adding a new component via the shadcn CLI, customizing an existing one, and building a custom component from primitives. All running in production.
The Black-Box Problem With Vendor Admin UIs
Every closed-admin CMS has the same architectural constraint: their admin is shipped as a built application, and your customizations have to fit their extension model. The model varies, but the friction is consistent.
Strapi: Custom fields require building a plugin (plugins/my-plugin/admin/src/components/...), registering the field type in register.js, and writing both input + display components. The plugin then has to be enabled per-project. The Strapi customization docs walk through ~7 steps just to swap a logo.
Sanity: Customizations live as Studio plugins. You write React components that render inside their iframe-isolated Studio, talk to their schema, and follow their patch protocol. Powerful, but you're learning Sanity's extension API before you write any business logic.
Payload: Better than most — Payload lets you write React directly. But custom field types still go through their plugin registration system, and components are scoped to Payload's field lifecycle.
The thing all three share: you're not editing the admin source code. You're authoring extensions that get loaded by the admin. The admin itself is a compiled bundle you can't touch.
This is fine when you trust the vendor, but it introduces real costs:
- Custom UI behavior has to fit the plugin model, even when it doesn't naturally
- Upgrading the CMS can break your plugins (different APIs across versions)
- You can't grep through the admin codebase to understand what's happening
- A simple change like "make this badge red on Wednesdays" requires a full plugin
Why shadcn/ui Breaks the Pattern
shadcn/ui isn't a component library you install. It's a CLI that copies component source files into your project. Run pnpm dlx shadcn@latest add button and you get components/ui/button.tsx checked into your repo.
For UnfoldCMS, this means 51 shadcn components live in cms/resources/js/components/ui/ as plain TypeScript files. The admin imports from @/components/ui/... — same as any React app. There's no admin SDK, no plugin manifest, no field registry.
Customizing a component is one operation: open the file, change it, save. Adding a new component is add from the shadcn CLI, or write a new file by hand. That's the entire extension story.
This is the concrete payoff of the shadcn admin template vs full CMS architecture choice — the admin compiles from your source code, not from a vendor package.
Tutorial 1: Add a New shadcn Component (Date Range Picker)
Say you want a date range picker for a "filter by date" feature on the posts admin page. shadcn/ui doesn't ship one by default — the official date-picker is single-date. So you'll either compose it from primitives or grab a community version.
Step 1 — Add the primitives
If calendar and popover aren't already installed (UnfoldCMS ships both), pull them via the CLI:
cd cms
pnpm dlx shadcn@latest add calendar popover
This drops calendar.tsx and popover.tsx into resources/js/components/ui/. Both are plain .tsx files using Radix primitives under the hood.
Step 2 — Write the component
Create cms/resources/js/components/ui/date-range-picker.tsx:
import * as React from "react"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import type { DateRange } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface DateRangePickerProps {
value?: DateRange
onChange?: (range: DateRange | undefined) => void
className?: string
placeholder?: string
}
export function DateRangePicker({
value,
onChange,
className,
placeholder = "Pick a date range",
}: DateRangePickerProps) {
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-[300px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} —{" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={value}
onSelect={onChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
)
}
Step 3 — Use it in an admin page
Open the posts index page and drop it in:
import { useState } from "react"
import type { DateRange } from "react-day-picker"
import { DateRangePicker } from "@/components/ui/date-range-picker"
export default function PostsIndex({ posts }: { posts: Post[] }) {
const [dateRange, setDateRange] = useState<DateRange | undefined>()
return (
<Card className="p-6">
<div className="flex items-center gap-4 mb-4">
<h2 className="text-lg font-semibold">Posts</h2>
<DateRangePicker value={dateRange} onChange={setDateRange} />
</div>
<DataTable columns={columns} data={filterByRange(posts, dateRange)} />
</Card>
)
}
Run pnpm run build, deploy, and the date range picker is live. No plugin registry. No field type to register. No vendor extension API.
That's roughly 10 minutes of work, most of which is writing your own filter logic.
Tutorial 2: Customize an Existing shadcn Component
Say you want a new Badge variant for "scheduled" posts — different from default, secondary, destructive, and outline. With Strapi or Sanity, this is a plugin. With UnfoldCMS, it's editing one file.
Open cms/resources/js/components/ui/badge.tsx. You'll see the cva (class-variance-authority) definition:
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
}
)
Add a scheduled variant in the same object:
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
scheduled: "border-transparent bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200",
},
},
Then use it anywhere:
<Badge variant="scheduled">Scheduled for Jun 23</Badge>
TypeScript will pick up the new variant in autocomplete since cva infers the variant type from the object keys. No plugin system, no type generation step.
Tutorial 3: Build a Custom Component From Primitives (Stat Card)
A "stat card" is a common dashboard pattern: big number, label, trend indicator. shadcn doesn't ship one, but you can compose it from Card, Badge, and a Lucide icon in 20 lines.
Create cms/resources/js/components/ui/stat-card.tsx:
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
interface StatCardProps {
label: string
value: string | number
delta?: number // percentage change (e.g. +12.5, -3.2)
icon?: LucideIcon
className?: string
}
export function StatCard({ label, value, delta, icon: Icon, className }: StatCardProps) {
const isUp = delta !== undefined && delta >= 0
const TrendIcon = isUp ? TrendingUp : TrendingDown
return (
<Card className={cn("p-5", className)}>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-3xl font-bold mt-1">{value}</p>
</div>
{Icon && (
<div className="rounded-md bg-primary/10 p-2">
<Icon className="h-5 w-5 text-primary" />
</div>
)}
</div>
{delta !== undefined && (
<div className="mt-3 flex items-center gap-1">
<Badge variant={isUp ? "default" : "destructive"} className="gap-1">
<TrendIcon className="h-3 w-3" />
{Math.abs(delta).toFixed(1)}%
</Badge>
<span className="text-xs text-muted-foreground">vs last week</span>
</div>
)}
</Card>
)
}
Use it on your dashboard:
import { StatCard } from "@/components/ui/stat-card"
import { FileText, Users, Eye } from "lucide-react"
export default function Dashboard({ stats }) {
return (
<div className="grid grid-cols-3 gap-4">
<StatCard label="Total Posts" value={stats.posts} delta={12.5} icon={FileText} />
<StatCard label="Subscribers" value={stats.subscribers} delta={3.8} icon={Users} />
<StatCard label="Pageviews (7d)" value="24.1K" delta={-2.1} icon={Eye} />
</div>
)
}
That's a complete custom dashboard component — the kind that would be a 200-line plugin in Strapi — in 25 lines, sitting next to the built-in shadcn components.
Using Your Custom Component in an Admin Page
UnfoldCMS uses Inertia 2 to bridge Laravel and React, so passing data from the backend to your component is direct:
// cms/resources/js/pages/admin/dashboard.tsx
import { Head } from "@inertiajs/react"
import { StatCard } from "@/components/ui/stat-card"
interface DashboardProps {
stats: {
posts: number
subscribers: number
pageviews7d: string
}
}
export default function Dashboard({ stats }: DashboardProps) {
return (
<>
<Head title="Dashboard" />
<div className="grid grid-cols-3 gap-4 p-6">
<StatCard label="Posts" value={stats.posts} />
<StatCard label="Subscribers" value={stats.subscribers} />
<StatCard label="Pageviews" value={stats.pageviews7d} />
</div>
</>
)
}
On the Laravel side, the controller just passes the data:
public function dashboard()
{
return Inertia::render('admin/dashboard', [
'stats' => [
'posts' => Post::count(),
'subscribers' => Subscriber::confirmed()->count(),
'pageviews7d' => $this->analytics->lastWeekPageviews(),
],
]);
}
No registration step. No "field type" to declare. The component is just an import.
What You Can't Do (Honest Limits)
The "it's your code" story has real limits worth flagging:
- Database schema changes — adding a new field to a post requires a Laravel migration and a model update. The UI is yours; the data layer follows Laravel conventions.
- Auth flow modifications — login/2FA/password reset are wired through Laravel Fortify. Customizing them is a Fortify customization, not a React one.
- Core CMS upgrades — when UnfoldCMS pushes an update, files you've edited in
components/ui/may conflict. This is the trade-off for owning the code: you handle merges. (In practice, shadcn components rarely change after the initial drop — they're stable surface area.) - Admin navigation — the sidebar is React state in
data/sidebar-data.ts. Adding a menu item is editing that file, not creating a plugin manifest.
These limits are the cost of using a Laravel + React stack. They're not vendor lock-in costs — every Laravel+React project has the same shape.
How This Compares to Strapi, Sanity, and Payload
| UnfoldCMS | Strapi | Sanity | Payload | |
|---|---|---|---|---|
| Custom field UI | Edit / add .tsx file |
Build plugin, register field | Studio plugin, dialog input | Custom field via plugin API |
| Where the code lives | Your codebase | plugins/my-plugin/ |
Sanity Studio config | Payload plugin folder |
| Registration step | None (just import) | register.js + package.json |
defineConfig({ plugins: [] }) |
payload.config.ts |
| Type safety | TypeScript infers from cva |
Hand-typed plugin contract | TypeScript via codegen | Generated from schema |
| Upgrade risk | You handle merges | Plugin API changes | Studio API changes | Plugin API changes |
| Setup time for a basic custom badge | 1 minute | ~30 minutes | ~30 minutes | ~15 minutes |
This isn't to say Strapi/Sanity/Payload are wrong — their extension models are intentional, and they pay off when you need sandboxed extensions or when teams of plugin authors need a stable contract. But for the common case of "I want to tweak the admin UI," UnfoldCMS removes the abstraction.
The Laravel + React + shadcn/ui stack post covers why this stack composition matters for DX in more depth, and the 50 shadcn components in a production admin post shows which components UnfoldCMS uses where.
Frequently Asked Questions
Do I need to recompile after editing a component?
Yes — UnfoldCMS uses Vite + Inertia. In development, pnpm run dev hot-reloads on save. For production, run pnpm run build (or php artisan deploy which does the build automatically).
Will my custom components survive a CMS update?
They live in your codebase, so a CMS update can't overwrite them — but if UnfoldCMS pushes changes to a file you've also edited (e.g., components/ui/button.tsx), you'll get a merge conflict to resolve. In practice, shadcn components rarely change after the initial drop, so conflicts are rare.
Can I share custom components between projects?
Yes. Copy the .tsx file from components/ui/ to another project, install any peer dependencies (most shadcn components depend on @radix-ui/* packages), and import it. No registration needed because there is no registration.
What if I want to add a new admin nav item?
Edit cms/resources/js/data/sidebar-data.ts and add an entry. It's a plain TypeScript array — no plugin manifest, no admin SDK call.
Can I publish my custom component as an npm package?
You can, but the typical shadcn pattern is to keep components in-repo. If you want shared infrastructure across multiple UnfoldCMS installations, a git submodule or an internal npm package both work — but the simpler path is usually copy-paste.
Bottom Line
Vendor admin UIs make custom components expensive. UnfoldCMS doesn't, because the admin isn't a vendor product — it's React code with shadcn components in your repository. Adding a custom field, customizing an existing variant, or composing a new component from primitives is the same operation: edit the file.
If you've been weighing UnfoldCMS against Strapi, Sanity, or Payload, the shadcn-native CMS landing page covers the stack story; the features page lists everything that ships out of the box; and pricing is on the site if you want to compare TCO.
Sources: shadcn/ui documentation (shadcn.com); Strapi customization docs (docs.strapi.io); Sanity Studio plugin guide (sanity.io/docs); Payload field type docs (payloadcms.com/docs); UnfoldCMS source code verified May 2026 (51 components in cms/resources/js/components/ui/).
Free & Open Source
Own your CMS. No subscriptions.
Unfold CMS is free to download and self-host. Built on Laravel + React, full source code included.
Share this post: