EmpireUI
Get Pro
← Blog8 min read#tailwind-css#admin-panel#crud-ui

Admin Panel Template with Tailwind: Full CRUD UI Layout

Build a full CRUD admin panel with Tailwind CSS v4. Sidebar layout, data tables, modals, and form components — all with real code you can drop in today.

Admin dashboard UI with sidebar navigation, data table, and form components on a dark background

Why Most Tailwind Admin Templates Miss the Point

Honestly, most admin templates you'll find on the internet are either overengineered messes with 14 dependencies, or stripped-down toy layouts that fall apart the moment you try to wire up real CRUD operations. This article is neither.

We're building a production-ready admin panel layout with Tailwind CSS v4.0.2, focusing on the parts that actually matter: sidebar navigation, a data table with row actions, a create/edit modal, and a delete confirmation flow. No premium license required. No 300KB bundle from a UI kit you only need 10% of.

The target stack is Next.js 15 with the App Router, React 19, and Tailwind v4. If you're on an older stack, 90% of this still applies — the layout code doesn't care. And if you haven't checked out what's new in Tailwind v4 yet, it changes a few things about how you'll write the CSS here.

Admin Layout Structure: Sidebar + Main Content Shell

The outer shell is a CSS grid split into two columns: a fixed-width sidebar and a flex-growing main area. We're using grid-cols-[240px_1fr] with a full viewport height. The sidebar doesn't scroll with the page — it's sticky top-0 h-screen overflow-y-auto.

One thing that trips people up: you want the main content area to be independently scrollable, not the viewport. Wrapping the content in overflow-y-auto h-screen on the right column handles this cleanly without any JavaScript scroll hacks.

Here's the shell. It's intentionally minimal — you build on top of it, you don't fight it:

// app/admin/layout.tsx
export default function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-[240px_1fr] h-screen bg-zinc-950 text-zinc-100">
      <aside className="sticky top-0 h-screen overflow-y-auto border-r border-zinc-800 bg-zinc-900 flex flex-col">
        <div className="px-5 py-4 text-sm font-semibold tracking-widest text-zinc-400 uppercase">
          Admin
        </div>
        <nav className="flex-1 px-3 pb-4 space-y-1">
          {/* nav items go here */}
        </nav>
      </aside>
      <main className="overflow-y-auto h-screen">
        <div className="max-w-6xl mx-auto px-8 py-10">
          {children}
        </div>
      </main>
    </div>
  )
}

Sidebar Navigation Items with Active State

The nav items need an active state that's obvious but not garish. A bg-zinc-800 background with a left border-l-2 border-indigo-500 accent is clean and readable at 13px font size. Don't go smaller — sidebar text at 12px in a dark theme is a accessibility problem waiting to happen.

In Next.js App Router, you'll use the usePathname hook to determine the active route. Keep this in a client component so you're not pulling the whole layout into client territory.

'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

const navItems = [
  { label: 'Dashboard', href: '/admin' },
  { label: 'Users', href: '/admin/users' },
  { label: 'Products', href: '/admin/products' },
  { label: 'Orders', href: '/admin/orders' },
  { label: 'Settings', href: '/admin/settings' },
]

export function SidebarNav() {
  const pathname = usePathname()
  return (
    <>
      {navItems.map((item) => {
        const active = pathname === item.href
        return (
          <Link
            key={item.href}
            href={item.href}
            className={[
              'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
              active
                ? 'bg-zinc-800 text-white border-l-2 border-indigo-500 pl-[10px]'
                : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50',
            ].join(' ')}
          >
            {item.label}
          </Link>
        )
      })}
    </>
  )
}

Notice the pl-[10px] hack on the active state. We're subtracting 2px from the default px-3 (12px) to compensate for the border-left width, so the text stays optically aligned. Small detail, but it matters when you're looking at this all day.

Data Table with Sortable Columns and Row Actions

A data table is the core of any admin panel. We're not using a heavy library here. A <table> with Tailwind utilities, a sort state in useState, and row-level action buttons is all you need for 90% of real use cases.

The table header gets cursor-pointer select-none on sortable columns. Visual sort indicators are just / text characters — no icon library needed. Each row has an Edit and Delete button in the last column. The delete button is text-red-400 hover:text-red-300 — visible but not screaming.

For spacing: th and td cells use px-4 py-3. Rows alternate background with even:bg-zinc-900/50. The outer wrapper is overflow-x-auto so the table doesn't blow the layout on smaller screens. If you're leaning into container query patterns, you can also hide lower-priority columns at narrow container widths rather than viewport widths — much more flexible in sidebar layouts like this.

Create and Edit Modal with Tailwind

The modal sits outside the table component, controlled by a modalState that holds either null (closed), { mode: 'create' }, or { mode: 'edit', data: Row }. One modal, two modes. Don't build two modals.

The backdrop is fixed inset-0 bg-black/60 backdrop-blur-sm z-40. The modal panel is fixed inset-0 z-50 flex items-center justify-center p-4. Inside, the card is bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg p-6 shadow-2xl. That rgba(255,255,255,0.15) border trick from glassmorphism designs works here too if you want a subtler look — check the glassmorphism deep-dive for the full technique.

// Simplified modal structure
{modalState && (
  <>
    <div
      className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
      onClick={() => setModalState(null)}
    />
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
      <div className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg p-6 shadow-2xl">
        <h2 className="text-lg font-semibold mb-6">
          {modalState.mode === 'create' ? 'New Record' : 'Edit Record'}
        </h2>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm text-zinc-400 mb-1.5">Name</label>
            <input
              className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm
                         focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
              defaultValue={modalState.data?.name ?? ''}
            />
          </div>
          <div className="flex justify-end gap-3 pt-2">
            <button
              type="button"
              onClick={() => setModalState(null)}
              className="px-4 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors"
            >
              Cancel
            </button>
            <button
              type="submit"
              className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors"
            >
              {modalState.mode === 'create' ? 'Create' : 'Save changes'}
            </button>
          </div>
        </form>
      </div>
    </div>
  </>
)}

Delete Confirmation Flow Without a Library

Deleting a record without a confirmation is a bad time. But pulling in a full toast/dialog library for one interaction is overkill. Here's the pattern: a pendingDeleteId state, a small inline confirmation bar that replaces the row actions temporarily.

When the user clicks Delete, set pendingDeleteId to that row's ID. The row re-renders showing 'Are you sure? Yes / No' inline. Confirming calls the delete handler and clears the state. Canceling just clears the state. No modal, no library, no problem. This keeps the user's eyes on the row they're deleting instead of opening a separate dialog.

Does this work for bulk deletes? Not really — for multi-select operations you'd want a proper confirmation modal. But for single-row deletion in a standard admin panel, the inline pattern is faster and less disruptive to use.

Theming the Admin Panel: Dark Mode and Color Tokens

If you're building something users will spend hours in, dark mode isn't optional. The zinc scale (zinc-900, zinc-800, zinc-700) is a good base — it's neutral without the green cast of the slate scale that can look off on warm monitors.

With Tailwind v4, you can define CSS custom properties directly in your @theme block and reference them as utilities. This beats hardcoding color classes everywhere when your client wants to swap brand colors. Here's a minimal token setup for an admin shell:

/* app/globals.css */
@import 'tailwindcss';

@theme {
  --color-admin-bg: #09090b;       /* zinc-950 */
  --color-admin-surface: #18181b;  /* zinc-900 */
  --color-admin-border: #27272a;   /* zinc-800 */
  --color-admin-accent: #6366f1;   /* indigo-500 */
  --color-admin-accent-hover: #818cf8; /* indigo-400 */
  --spacing-sidebar: 240px;
}

With these tokens defined, you write bg-admin-bg instead of bg-zinc-950 throughout. Swapping to a client's brand color is a one-line change. If you want to go deeper on the OKLCH-based color system in Tailwind v4 — which gives you perceptually uniform color steps — that article covers it properly. It's genuinely useful for building consistent button and badge color scales across an admin UI.

Adding a theme toggle to switch between light and dark admin modes is also worth considering if you're shipping this as a real product. The implementation is straightforward with a data-theme attribute on the root element.

Putting It Together: Page-Level CRUD with Server Actions

In Next.js 15, the cleanest pattern for admin CRUD is: Server Component page that fetches data, passes it to a Client Component that owns modal/table state, and calls Server Actions for mutations. No separate API routes needed for this pattern.

The Server Action for create/update validates with Zod, writes to the DB, and calls revalidatePath('/admin/users'). The client component gets back a result object and either closes the modal or shows an inline error. Total round-trip for a create is one function call. It's straightforward and it works.

How do you handle optimistic updates in this setup? For small admin lists, you don't need to. The revalidation is fast enough. For large datasets with paginated tables, you'd add useOptimistic on the client side — but that's a separate article. Start without it, add it when latency becomes a real user complaint, not before.

FAQ

Can I use this admin layout with Tailwind v3 instead of v4?

Mostly yes. The main difference is the @theme block syntax — in v3, you'd put custom colors in tailwind.config.js under extend.colors instead. The layout utilities and spacing values work identically in both versions.

How do I make the sidebar collapsible for smaller screens?

Add a boolean sidebarOpen state in a client layout wrapper. Toggle between grid-cols-[240px_1fr] and grid-cols-[0px_1fr] (or grid-cols-[64px_1fr] for an icon-only collapsed state). Animate with transition-[grid-template-columns] duration-200. You'll also want a hamburger button in the mobile header to trigger the toggle.

What's the best way to handle table pagination with this setup?

Use URL search params for page number and page size (?page=2&limit=25). Read them in your Server Component with searchParams prop, pass them to your DB query with OFFSET and LIMIT. The pagination UI is a client component that uses useRouter().push() to update the URL. This pattern makes pagination bookmarkable and shareable automatically.

How do I add role-based access control to hide certain nav items?

Fetch the current user's role in your Server Component layout (from session/JWT), pass it as a prop or via a context to the SidebarNav component. Filter navItems based on role before rendering. Never rely solely on hiding UI elements for security — validate permissions in your Server Actions too.

Is there a way to add a global search bar to this admin panel?

Yes. Add an <input> to the top of the sidebar or in a top bar. On input change, debounce 300ms then call a Server Action that does a database search across relevant tables. Return results as a dropdown list. For fuzzy search across multiple tables, Postgres full-text search with to_tsvector works well without adding a separate search service.

How do I handle file uploads in the create/edit modal?

Add an <input type='file'> to the form. In your Server Action, read it from FormData with formData.get('file') — you get a File object. Upload to S3/R2/Supabase Storage, then save the returned URL to your database. Don't store file contents in the DB itself. Set encType='multipart/form-data' on the form element when mixing file and text fields.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Building a Full Design System with Tailwind in 2026Tailwind Dark Mode: class vs media, system preference, manual toggleshadcn/ui vs Radix vs Headless UI: Choosing Your Component BaseDesign System Migration: Moving Teams from Bootstrap to Custom