A Year of Building With shadcn: 10 Patterns That Survived Production

Tactical Code Patterns From 12 Months of Production shadcn/ui

July 1, 2026 · 11 min read
A Year of Building With shadcn: 10 Patterns That Survived Production

A previous post (shadcn/ui in Production: 1 Year, 51 Components, 205 Admin Pages — Lessons) covered the what — what we learned about composition, variant discipline, and Tailwind v4 across 12 months of building UnfoldCMS on shadcn/ui. This post is the how. Ten specific code patterns we ship today that survived production. Tactical, copy-pasteable, field-tested at scale.

If you're picking shadcn for a real admin in 2026, these are the patterns I'd open a fresh repo with on day one. Every one of them is in our cms/resources/js/components/ directory right now, running across 51 shadcn components and 205 admin pages.

Disclosure: I work on UnfoldCMS. The patterns are ours; the shadcn library is upstream. None of this is theoretical.

TL;DR — the 10 patterns

  1. Form schemas with react-hook-form + Zod + shadcn <Form> primitives — one declarative source of truth per form.
  2. DataTable columns as data, not JSXaccessorKey + cell function, no inline <td> work.
  3. Theme switching via CSS variables, not classes — three production themes from one codebase.
  4. Sheet for mobile, Dialog for desktop, same content — one component renders responsive.
  5. Layout primitives over per-page layouts — three foundational layout components handle 90% of pages.
  6. Server-side state with @tanstack/react-table — pagination, filtering, sorting all server-side.
  7. Variants only after the third use — resist the urge until pattern repeats three times.
  8. Type-safe URLs with Inertia + Wayfinder — no string routes anywhere.
  9. useFormStatus-style server-action pattern — submitting states are first-class.
  10. Storybook for variant regression, not docs — catch collisions, not for editor-facing docs.

Pattern 1 — Form schemas with react-hook-form + Zod + shadcn <Form>

Every form in the admin uses the same pattern. One Zod schema declares fields and validation. react-hook-form binds to inputs. The shadcn <Form>, <FormField>, <FormItem>, <FormLabel>, <FormControl>, <FormMessage> primitives render and surface errors.

const schema = z.object({
  title: z.string().min(1).max(200),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  body: z.string().min(1),
});

type FormData = z.infer<typeof schema>;

function PostForm({ defaultValues, onSubmit }) {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues,
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Title</FormLabel>
              <FormControl><Input {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* ...other fields */}
      </form>
    </Form>
  );
}

Every form across 205 pages uses this exact shape. New form? Write the schema. Add the fields. Bind. Done. No bespoke validation logic. No field-by-field state. The discipline pays back at month three when adding a new form is genuinely a 10-minute task.

Pattern 2 — DataTable columns as data, not JSX

Tables are the second-most-common admin page after forms. Our <DataTable> is a @tanstack/react-table wrapper. The trick that scales: columns are plain objects, never JSX.

const columns: ColumnDef<Post>[] = [
  {
    accessorKey: 'title',
    header: 'Title',
    cell: ({ row }) => <PostTitleCell post={row.original} />,
    enableSorting: true,
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => <Badge variant={row.original.status}>{row.original.status}</Badge>,
  },
  {
    id: 'actions',
    cell: ({ row }) => <PostActions post={row.original} />,
  },
];

JSX only inside cell. Column definitions stay portable between tables (the status cell is shared between posts and pages). Sorting / filtering / pagination logic lives on the DataTable, not on individual table instances.

This pattern alone collapsed about 1,200 lines of inline JSX across our 11 production tables into 200 lines of column definitions + 8 shared cell components.

Pattern 3 — Theme switching via CSS variables, not classes

Three production themes ship from one codebase. The mechanism is a data-theme attribute on <html> plus per-theme CSS variable blocks in resources/css/theme.css:

:root {
  --primary: oklch(0.55 0.27 262);
  --primary-foreground: oklch(0.98 0 0);
}

[data-theme="purple"] {
  --primary: oklch(0.6 0.25 295);
}

[data-theme="unfold"] {
  --primary: oklch(0.7 0.12 280);
  --primary-foreground: oklch(0.13 0.03 262);
}

The 51 components use bg-primary, text-primary-foreground, etc. They consume CSS variables, not Tailwind theme tokens. Switching themes is a one-line attribute change with zero rebuild.

We covered the longer architecture in Theming a shadcn CMS: 3 Themes from One Codebase. For agencies running per-client themes, this is the underlying mechanism.

Pattern 4 — Sheet for mobile, Dialog for desktop, same content

A common admin pattern: an "Add new" or "Edit" UI that's a modal on desktop and a slide-up sheet on mobile. The naive implementation is two duplicate components. The right one is a hook that returns the appropriate wrapper based on viewport.

function useResponsiveModal() {
  const isDesktop = useMediaQuery('(min-width: 768px)');
  return isDesktop ? Dialog : Sheet;
}

function EditPostModal({ post, onSave }) {
  const Modal = useResponsiveModal();
  return (
    <Modal>
      <ModalContent>
        <PostForm defaultValues={post} onSubmit={onSave} />
      </ModalContent>
    </Modal>
  );
}

Same content, different chrome based on viewport. Saves ~300 lines per modal-using page across the admin.

Pattern 5 — Layout primitives over per-page layouts

Three layout components handle 90% of admin pages: <PageContainer>, <PageHeader>, <PageContent>. They're simple composition wrappers with consistent padding and spacing:

function PageContainer({ children }) {
  return <div className="container mx-auto py-8 space-y-6">{children}</div>;
}

function PageHeader({ title, description, actions }) {
  return (
    <header className="flex items-center justify-between">
      <div>
        <h1 className="text-2xl font-bold">{title}</h1>
        {description && <p className="text-muted-foreground">{description}</p>}
      </div>
      {actions && <div className="flex items-center gap-2">{actions}</div>}
    </header>
  );
}

The temptation early was to build a <DashboardLayout> with sidebar + topbar + breadcrumbs all baked in. We tried it. It broke the second a settings page needed a different structure. Layout primitives compose freely; layout components don't.

Pattern 6 — Server-side state with @tanstack/react-table

For tables backed by a server (which is every CMS table), the rookie mistake is loading all rows client-side and filtering in-memory. It works at 100 rows. It breaks at 10,000.

The pattern: TanStack Table's manualPagination, manualFiltering, manualSorting modes. The table doesn't filter or sort; it reports state changes. Your loader (Inertia, fetch, whatever) re-queries the server with the new state.

const table = useReactTable({
  data,
  columns,
  manualPagination: true,
  manualFiltering: true,
  manualSorting: true,
  state: { pagination, columnFilters, sorting },
  onPaginationChange: setPagination,
  onColumnFiltersChange: setColumnFilters,
  onSortingChange: setSorting,
});

useEffect(() => {
  router.get(currentUrl, {
    page: pagination.pageIndex + 1,
    per_page: pagination.pageSize,
    sort: sorting,
    filters: columnFilters,
  }, { preserveState: true });
}, [pagination, sorting, columnFilters]);

Scales to any row count. Server stays the source of truth. Every column is sortable without page-specific logic.

Pattern 7 — Variants only after the third use

This is the discipline that keeps <Button> from having 14 sizes by month six. The rule: don't add a shadcn variant until you've needed the same shape three times. The first two times, override with className. The third time, that's signal — add the variant.

We documented this in the year-one lessons post. The discipline matters because variants are forever. A size="xs" you add in month two will be referenced by 30 pages by month twelve. Removing it later is real work.

Pattern 8 — Type-safe URLs with Inertia + Wayfinder

UnfoldCMS uses Inertia.js for the React-Laravel bridge plus Wayfinder for type-safe route generation. We never write string URLs in React code. The pattern:

import { posts } from '@/routes';

<Link href={posts.index().url}>All posts</Link>
<Link href={posts.show(post.id).url}>{post.title}</Link>
<Button onClick={() => router.delete(posts.destroy(post.id).url)}>
  Delete
</Button>

Wayfinder generates a typed routes object from Laravel's route file. Wrong URL = TypeScript error. Renamed route in Laravel = compile-time error in React. No production "404 because someone typo'd the slug" bugs.

For more on the stack pattern, see Laravel + React + shadcn/ui: The Modern CMS Stack.

Pattern 9 — useFormStatus-style server-action pattern

For server-side mutations (delete, save, publish, etc.), the pattern is useTransition or React 19's form-action handling. The submitting state is first-class:

const [isPending, startTransition] = useTransition();

async function handleSave(values: FormData) {
  startTransition(async () => {
    await router.post(posts.store().url, values);
  });
}

<Button type="submit" disabled={isPending}>
  {isPending ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>

Every server mutation shows submitting state. No race conditions on double-click. No "did my save actually fire?" anxiety for editors. The discipline is to never write a server-action handler without surfacing the pending state in the UI.

Pattern 10 — Storybook for variant regression, not docs

We added Storybook around month six. The use case wasn't editor-facing component docs — those weren't valuable for an internal admin. It was catching variant collisions during development.

Each component has a .stories.tsx file with one story per variant. When someone changes a component, the Storybook visual regression catches whether existing variants still look right. It's caught about a dozen subtle layout regressions over 18 months — variants that compiled fine but rendered broken.

For a CMS admin team where the same component is used in 50+ places, this is cheap insurance. Add Storybook in week two of the next project; don't wait six months.

What we'd do differently

If we were starting over with these patterns in mind:

  • Add Storybook earlier. Week two, not month six.
  • Start with TanStack Table from day one. We hand-rolled tables for the first six months and refactored later. Wasted work.
  • Lock the layout primitives in week one. Before adding the second page.
  • Use Wayfinder before there are 10 routes. Adding it later is a refactor.

For the broader year-one lessons, see shadcn/ui in Production: 1 Year, 51 Components, 205 Admin Pages — Lessons.

People Also Ask

What's the best shadcn form pattern?

react-hook-form + Zod + shadcn's <Form> primitives, used consistently across every form in the admin. One schema per form, one bound useForm, the shadcn Form primitives handle rendering and error display. This pattern scales linearly: each new form is 10-15 minutes once you know the shape.

How do I scale shadcn DataTable across many pages?

Define columns as data (plain objects with accessorKey, header, cell), not JSX. Use TanStack Table's manualPagination / manualFiltering / manualSorting modes for server-backed tables. Share cell components across tables (e.g. one <StatusBadge> used in posts, users, comments).

Should I use shadcn's Block library or build from components?

Both. Use Blocks for onboarding pages, login, and sectioned layouts that don't need customisation. Build from components for the parts that need fork-and-modify control (data tables, custom forms, complex composition). Blocks save time on pages that don't matter for product differentiation.

What's the right Storybook setup for a shadcn admin?

One story per variant per component. Use it for variant regression, not editor docs. Visual regression catches the "this compiled fine but renders broken" bugs that other tests miss. Add it in week two of the project.

How do I keep shadcn upstream in sync with my modifications?

Bi-weekly audit. Maintain a MODIFIED.md listing locally-modified components. Diff your fork against upstream every other Monday. About 80% of changes apply cleanly; 10% need manual merge; 10% you skip. We covered the workflow in shadcn/ui Roadmap and Our CMS: How We Track Upstream Changes.

Bottom line

These ten patterns are field-tested across 12 months and 205 production admin pages. They're not theoretical, they're not "this is how shadcn recommends," they're "this is what we run, and it didn't blow up." If you're starting a new admin on shadcn/ui in 2026, open the repo with these in place from day one. The discipline pays back immediately.

For the broader narrative on why we picked shadcn at all, see The CMS Built on shadcn/ui: Why It Matters. To see the production result, try the UnfoldCMS demo.


Sources and methodology

  • shadcn/ui official docsui.shadcn.com, official forms guide, official data-table guide, Blocks library.
  • react-hook-form + Zod integrationreact-hook-form docs, zod docs.
  • TanStack Tabletanstack.com/table, used for our <DataTable> wrapper.
  • Wayfinder — Laravel route → TypeScript bridge, used in UnfoldCMS for type-safe URLs.
  • UnfoldCMS countsfind cms/resources/js/components/ui -name "*.tsx" \| wc -l = 51; find cms/resources/js/pages/admin -name "*.tsx" \| wc -l = 205.
  • Patterns documented in our internal MODIFIED.md and pulled from production code. Every snippet above is a simplification of a real pattern in cms/resources/js/.
  • 12-month timeline drawn from internal commit history, April 2024 - June 2026.

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:

Discussion

Comments (0)

Leave a Comment

Please log in to leave a comment.

Don't have an account? Register here

No comments yet. Be the first to share your thoughts!

Keep Reading

Related Posts

Back to all posts
Powered by UnfoldCMS