Skip to main content
Back to Blog
Tailwind CSSShadcn UIReactDashboardDark ModeAuthenticationTypeScriptNext.jsUI ComponentsFrontend

Building a Dashboard UI with Tailwind CSS and Shadcn UI

A comprehensive project-based tutorial for building a production-ready dashboard UI with Tailwind CSS and Shadcn UI — covering authentication flows, dark mode toggling, responsive layouts, and interactive components from scratch.

April 27, 202616 min readNiraj Kumar

Modern web applications demand more than just functional interfaces — they need to be beautiful, accessible, responsive, and fast. In this tutorial, we'll build a complete, production-grade dashboard from the ground up using Tailwind CSS and Shadcn UI — two tools that have fundamentally changed how developers build UIs in 2026.

By the end of this guide, you'll have:

  • A fully responsive dashboard layout with a collapsible sidebar
  • Authentication-aware UI (login page, protected routes)
  • A working dark/light mode toggle with persistence
  • Interactive data components: charts, tables, cards, and modals
  • Best practices for component architecture and scalability

Let's dive in.


🧠 Why Tailwind CSS + Shadcn UI?

Before we write a single line of code, it's worth understanding why this stack has become the go-to choice for production dashboards.

Tailwind CSS

Tailwind is a utility-first CSS framework that lets you style elements by composing small, single-purpose utility classes directly in your markup. Instead of writing custom CSS like:

.card {
  background-color: white;
  border-radius: 0.5rem;
  padding: 1.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

You write:

<div className="bg-white rounded-lg p-6 shadow-sm">...</div>

This approach leads to:

  • Faster development — no context switching between files
  • Zero dead CSS — only used utilities end up in the final bundle (via PurgeCSS/tree-shaking)
  • Easy consistency — design tokens (colors, spacing, fonts) are defined once and used everywhere
  • Responsive design by default — with breakpoint prefixes like md:, lg:, xl:

Shadcn UI

Shadcn UI is not a traditional component library. It's a collection of beautifully designed, accessible components that you copy directly into your project. This gives you:

  • Full control — components live in your codebase, not inside node_modules
  • Customizability — tweak anything without fighting library abstractions
  • Accessibility built-in — powered by Radix UI primitives
  • TypeScript support — every component is fully typed

Together, Tailwind + Shadcn gives you the design velocity of a component library with the flexibility of writing everything from scratch.


🛠️ Project Setup

Prerequisites

Ensure you have:

  • Node.js v20+
  • pnpm (recommended) or npm
  • Basic familiarity with React and TypeScript

Step 1: Initialize a Next.js Project

We'll use Next.js 15 with the App Router:

pnpm create next-app@latest my-dashboard \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

cd my-dashboard

Step 2: Install and Initialize Shadcn UI

pnpm dlx shadcn@latest init

You'll be prompted with configuration questions. Use these settings:

✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? › Yes

This generates a components.json and adds CSS variable definitions to globals.css.

Step 3: Install Core Components

pnpm dlx shadcn@latest add button card input label \
  dropdown-menu sheet avatar badge separator \
  table dialog tooltip skeleton switch

Step 4: Install Additional Dependencies

pnpm add recharts lucide-react next-themes clsx tailwind-merge
pnpm add -D @types/node

📁 Project Structure

Organize your project for scalability:

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── (dashboard)/
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   ├── analytics/
│   │   │   └── page.tsx
│   │   ├── users/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── globals.css
│   └── layout.tsx
├── components/
│   ├── ui/               ← Shadcn components live here
│   ├── dashboard/
│   │   ├── Sidebar.tsx
│   │   ├── Header.tsx
│   │   ├── StatsCard.tsx
│   │   ├── RevenueChart.tsx
│   │   └── RecentActivity.tsx
│   └── auth/
│       └── LoginForm.tsx
├── lib/
│   └── utils.ts
└── hooks/
    └── use-sidebar.ts

The route groups (auth) and (dashboard) are Next.js App Router conventions — they group routes under shared layouts without affecting the URL.


🔐 Authentication UI

A dashboard almost always starts with a login screen. Let's build one that's clean and professional.

The Login Form Component

// src/components/auth/LoginForm.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Eye, EyeOff, LayoutDashboard } from "lucide-react";

export function LoginForm() {
  const [showPassword, setShowPassword] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setIsLoading(true);

    // Simulate API call
    await new Promise((resolve) => setTimeout(resolve, 1500));
    
    // In production: call your auth endpoint here
    // then redirect to /dashboard
    setIsLoading(false);
  }

  return (
    <Card className="w-full max-w-md shadow-lg">
      <CardHeader className="space-y-1 text-center">
        <div className="flex justify-center mb-2">
          <div className="p-3 bg-primary/10 rounded-full">
            <LayoutDashboard className="h-6 w-6 text-primary" />
          </div>
        </div>
        <CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
        <CardDescription>Sign in to access your dashboard</CardDescription>
      </CardHeader>
      <CardContent>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              placeholder="admin@company.com"
              required
              autoComplete="email"
            />
          </div>
          <div className="space-y-2">
            <div className="flex items-center justify-between">
              <Label htmlFor="password">Password</Label>
              <a
                href="#"
                className="text-sm text-primary hover:underline underline-offset-4"
              >
                Forgot password?
              </a>
            </div>
            <div className="relative">
              <Input
                id="password"
                type={showPassword ? "text" : "password"}
                placeholder="••••••••"
                required
                autoComplete="current-password"
                className="pr-10"
              />
              <button
                type="button"
                onClick={() => setShowPassword(!showPassword)}
                className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
                aria-label={showPassword ? "Hide password" : "Show password"}
              >
                {showPassword ? (
                  <EyeOff className="h-4 w-4" />
                ) : (
                  <Eye className="h-4 w-4" />
                )}
              </button>
            </div>
          </div>
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? "Signing in..." : "Sign in"}
          </Button>
        </form>
      </CardContent>
    </Card>
  );
}

The Login Page Layout

// src/app/(auth)/login/page.tsx
import { LoginForm } from "@/components/auth/LoginForm";

export default function LoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
      <LoginForm />
    </div>
  );
}

Key design decisions here:

  • The (auth) route group has its own layout, completely separate from the dashboard layout
  • The password toggle uses an aria-label for accessibility
  • The loading state disables the button and provides feedback without a spinner library

🌙 Dark Mode with next-themes

Dark mode is a first-class requirement in 2026. Let's implement it properly — with persistence, no flash of wrong theme (FOWT), and a smooth toggle.

Wrap with ThemeProvider

// src/app/layout.tsx
import { ThemeProvider } from "next-themes";
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "My Dashboard",
  description: "A production-ready admin dashboard",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

⚠️ The suppressHydrationWarning on <html> is intentional — next-themes injects a class during SSR that may differ from hydration, and this suppresses the React warning.

The Theme Toggle Component

// src/components/dashboard/ThemeToggle.tsx
"use client";

import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ThemeToggle() {
  const { setTheme, theme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" aria-label="Toggle theme">
          <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          <Sun className="mr-2 h-4 w-4" />
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          <Moon className="mr-2 h-4 w-4" />
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          <Monitor className="mr-2 h-4 w-4" />
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

The rotating sun/moon animation uses pure Tailwind CSS classes — no extra animation library needed.


🗂️ Responsive Dashboard Layout

The layout is the backbone of any dashboard. We need:

  1. A fixed sidebar on desktop
  2. A slide-out drawer on mobile
  3. A top header with navigation and user actions

Custom Sidebar Hook

// src/hooks/use-sidebar.ts
import { create } from "zustand";

interface SidebarStore {
  isOpen: boolean;
  toggle: () => void;
  close: () => void;
}

export const useSidebar = create<SidebarStore>((set) => ({
  isOpen: false,
  toggle: () => set((state) => ({ isOpen: !state.isOpen })),
  close: () => set({ isOpen: false }),
}));

If you prefer not to use Zustand, a simple useState in a shared context works too.

// src/components/dashboard/Sidebar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import {
  LayoutDashboard,
  BarChart3,
  Users,
  Settings,
  Bell,
  HelpCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";

const navItems = [
  { href: "/dashboard", icon: LayoutDashboard, label: "Overview" },
  { href: "/analytics", icon: BarChart3, label: "Analytics" },
  { href: "/users", icon: Users, label: "Users", badge: "New" },
  { href: "/notifications", icon: Bell, label: "Notifications", badge: "3" },
  { href: "/settings", icon: Settings, label: "Settings" },
  { href: "/help", icon: HelpCircle, label: "Help & Support" },
];

interface SidebarProps {
  className?: string;
}

export function Sidebar({ className }: SidebarProps) {
  const pathname = usePathname();

  return (
    <aside
      className={cn(
        "flex flex-col h-full bg-card border-r px-3 py-4 w-64",
        className
      )}
    >
      {/* Logo */}
      <div className="flex items-center gap-2 px-3 mb-8">
        <div className="p-1.5 bg-primary rounded-md">
          <LayoutDashboard className="h-5 w-5 text-primary-foreground" />
        </div>
        <span className="font-bold text-lg tracking-tight">Acme Dashboard</span>
      </div>

      {/* Navigation */}
      <nav className="flex-1 space-y-1">
        {navItems.map(({ href, icon: Icon, label, badge }) => {
          const isActive = pathname === href;
          return (
            <Link
              key={href}
              href={href}
              className={cn(
                "flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
                isActive
                  ? "bg-primary text-primary-foreground"
                  : "text-muted-foreground hover:bg-muted hover:text-foreground"
              )}
            >
              <Icon className="h-4 w-4 shrink-0" />
              <span className="flex-1">{label}</span>
              {badge && (
                <Badge
                  variant={isActive ? "secondary" : "outline"}
                  className="text-xs"
                >
                  {badge}
                </Badge>
              )}
            </Link>
          );
        })}
      </nav>

      {/* User Profile at Bottom */}
      <div className="mt-auto pt-4 border-t">
        <div className="flex items-center gap-3 px-3 py-2">
          <div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-semibold text-primary">
            JD
          </div>
          <div className="flex-1 min-w-0">
            <p className="text-sm font-medium truncate">Jane Doe</p>
            <p className="text-xs text-muted-foreground truncate">
              jane@acme.com
            </p>
          </div>
        </div>
      </div>
    </aside>
  );
}

Dashboard Layout

// src/app/(dashboard)/layout.tsx
"use client";

import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { ThemeToggle } from "@/components/dashboard/ThemeToggle";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen overflow-hidden">
      {/* Desktop Sidebar */}
      <div className="hidden md:flex">
        <Sidebar />
      </div>

      {/* Main Content Area */}
      <div className="flex-1 flex flex-col min-w-0 overflow-auto">
        {/* Top Header */}
        <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background/80 backdrop-blur-sm px-4 md:px-6">
          {/* Mobile Menu Trigger */}
          <Sheet>
            <SheetTrigger asChild>
              <Button
                variant="ghost"
                size="icon"
                className="md:hidden"
                aria-label="Open menu"
              >
                <Menu className="h-5 w-5" />
              </Button>
            </SheetTrigger>
            <SheetContent side="left" className="p-0 w-64">
              <Sidebar />
            </SheetContent>
          </Sheet>

          <div className="flex-1" />

          {/* Header Actions */}
          <ThemeToggle />
        </header>

        {/* Page Content */}
        <main className="flex-1 p-4 md:p-6 lg:p-8">{children}</main>
      </div>
    </div>
  );
}

The backdrop-blur-sm on the header creates a frosted-glass effect as content scrolls beneath it — a subtle but professional touch.


📊 Interactive Dashboard Components

Now for the fun part — filling the dashboard with meaningful, interactive components.

Stats Cards

// src/components/dashboard/StatsCard.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";

interface StatsCardProps {
  title: string;
  value: string;
  change: number; // percentage, positive = up, negative = down
  icon: LucideIcon;
  description?: string;
}

export function StatsCard({
  title,
  value,
  change,
  icon: Icon,
  description,
}: StatsCardProps) {
  const isPositive = change > 0;
  const isNeutral = change === 0;

  const TrendIcon = isNeutral
    ? Minus
    : isPositive
    ? TrendingUp
    : TrendingDown;

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">
          {title}
        </CardTitle>
        <div className="p-2 bg-primary/10 rounded-lg">
          <Icon className="h-4 w-4 text-primary" />
        </div>
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        <div className="flex items-center gap-1 mt-1">
          <TrendIcon
            className={cn(
              "h-3.5 w-3.5",
              isPositive
                ? "text-emerald-500"
                : isNeutral
                ? "text-muted-foreground"
                : "text-red-500"
            )}
          />
          <p
            className={cn(
              "text-xs font-medium",
              isPositive
                ? "text-emerald-500"
                : isNeutral
                ? "text-muted-foreground"
                : "text-red-500"
            )}
          >
            {isPositive ? "+" : ""}
            {change}% from last month
          </p>
        </div>
        {description && (
          <p className="text-xs text-muted-foreground mt-1">{description}</p>
        )}
      </CardContent>
    </Card>
  );
}

Revenue Chart with Recharts

// src/components/dashboard/RevenueChart.tsx
"use client";

import {
  Area,
  AreaChart,
  CartesianGrid,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

const data = [
  { month: "Jan", revenue: 4200, target: 4000 },
  { month: "Feb", revenue: 3800, target: 4200 },
  { month: "Mar", revenue: 5100, target: 4500 },
  { month: "Apr", revenue: 4700, target: 4800 },
  { month: "May", revenue: 6200, target: 5000 },
  { month: "Jun", revenue: 5800, target: 5500 },
  { month: "Jul", revenue: 7100, target: 6000 },
  { month: "Aug", revenue: 6900, target: 6500 },
  { month: "Sep", revenue: 8200, target: 7000 },
  { month: "Oct", revenue: 7600, target: 7500 },
  { month: "Nov", revenue: 9100, target: 8000 },
  { month: "Dec", revenue: 10200, target: 9000 },
];

export function RevenueChart() {
  return (
    <Card className="col-span-full lg:col-span-2">
      <CardHeader>
        <CardTitle>Revenue Overview</CardTitle>
        <CardDescription>
          Monthly revenue vs. target for the current year
        </CardDescription>
      </CardHeader>
      <CardContent>
        <ResponsiveContainer width="100%" height={300}>
          <AreaChart
            data={data}
            margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
          >
            <defs>
              <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.2} />
                <stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
              </linearGradient>
              <linearGradient id="targetGradient" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor="hsl(var(--muted-foreground))" stopOpacity={0.1} />
                <stop offset="95%" stopColor="hsl(var(--muted-foreground))" stopOpacity={0} />
              </linearGradient>
            </defs>
            <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
            <XAxis
              dataKey="month"
              tick={{ fontSize: 12 }}
              tickLine={false}
              axisLine={false}
              className="text-muted-foreground"
            />
            <YAxis
              tick={{ fontSize: 12 }}
              tickLine={false}
              axisLine={false}
              tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
              className="text-muted-foreground"
            />
            <Tooltip
              formatter={(value: number, name: string) => [
                `$${value.toLocaleString()}`,
                name === "revenue" ? "Revenue" : "Target",
              ]}
              contentStyle={{
                borderRadius: "0.5rem",
                border: "1px solid hsl(var(--border))",
                backgroundColor: "hsl(var(--card))",
                color: "hsl(var(--card-foreground))",
              }}
            />
            <Area
              type="monotone"
              dataKey="target"
              stroke="hsl(var(--muted-foreground))"
              strokeWidth={1.5}
              strokeDasharray="4 4"
              fill="url(#targetGradient)"
            />
            <Area
              type="monotone"
              dataKey="revenue"
              stroke="hsl(var(--primary))"
              strokeWidth={2}
              fill="url(#revenueGradient)"
            />
          </AreaChart>
        </ResponsiveContainer>
      </CardContent>
    </Card>
  );
}

Putting It All Together — Dashboard Page

// src/app/(dashboard)/dashboard/page.tsx
import { DollarSign, Users, ShoppingCart, TrendingUp } from "lucide-react";
import { StatsCard } from "@/components/dashboard/StatsCard";
import { RevenueChart } from "@/components/dashboard/RevenueChart";

const stats = [
  {
    title: "Total Revenue",
    value: "$84,320",
    change: 12.5,
    icon: DollarSign,
    description: "Based on paid invoices",
  },
  {
    title: "Active Users",
    value: "3,241",
    change: 8.2,
    icon: Users,
    description: "Logged in last 30 days",
  },
  {
    title: "New Orders",
    value: "1,189",
    change: -3.1,
    icon: ShoppingCart,
    description: "Compared to last period",
  },
  {
    title: "Growth Rate",
    value: "24.7%",
    change: 4.6,
    icon: TrendingUp,
    description: "Quarter over quarter",
  },
];

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">Good morning, Jane 👋</h1>
        <p className="text-muted-foreground">
          Here's what's happening with your business today.
        </p>
      </div>

      {/* Stats Grid */}
      <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
        {stats.map((stat) => (
          <StatsCard key={stat.title} {...stat} />
        ))}
      </div>

      {/* Charts Grid */}
      <div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
        <RevenueChart />
        {/* Additional charts or tables can go here */}
      </div>
    </div>
  );
}

⚠️ Common Mistakes to Avoid

1. Hardcoding Colors Instead of Using CSS Variables

Wrong:

<div className="bg-[#ffffff] text-[#0f172a]">...</div>

Right:

<div className="bg-background text-foreground">...</div>

Shadcn's CSS variables (--background, --foreground, etc.) automatically swap values in dark mode. Hardcoded hex values break dark mode.

2. Missing "use client" Directives

In Next.js App Router, components are Server Components by default. Interactive components that use hooks, event handlers, or browser APIs must have "use client" at the top.

Wrong: Using useState in a Server Component.

Right: Add "use client" to any component using React hooks.

3. Forgetting suppressHydrationWarning

When using next-themes, always add suppressHydrationWarning to your <html> tag. Without it, you'll see React hydration mismatch warnings in development.

4. Importing Shadcn Components Incorrectly

Wrong:

import { Button } from "shadcn/ui"; // This doesn't exist!

Right:

import { Button } from "@/components/ui/button"; // Locally installed

5. Not Handling Mobile Navigation

A sidebar that's always visible on all screen sizes breaks mobile UX. Use Tailwind's hidden md:flex pattern combined with Shadcn's Sheet component for a proper mobile drawer.

6. Overusing any in TypeScript

Dashboard components often deal with dynamic data. Resist the temptation to use any:

Wrong:

function StatsCard({ data }: { data: any }) {...}

Right:

interface StatsData {
  title: string;
  value: string;
  change: number;
}
function StatsCard({ data }: { data: StatsData }) {...}

🚀 Pro Tips

  • Use cn() everywhere: The cn utility (from clsx + tailwind-merge) prevents Tailwind class conflicts and makes conditional styling clean. Always use it over template literals.

  • Leverage Shadcn's variant system: Shadcn components use class-variance-authority (CVA) internally. Learn to add your own variants to components rather than adding one-off class overrides.

  • Prefer backdrop-blur for floating UI: Headers, modals, and tooltips look significantly more polished with backdrop-blur-sm and a semi-transparent background (bg-background/80).

  • Use CSS Grid for dashboard layouts: Tailwind's grid system (grid-cols-1 sm:grid-cols-2 xl:grid-cols-4) is more powerful than flexbox for 2D dashboard layouts.

  • Animate skeleton states: Instead of showing nothing while data loads, use Shadcn's Skeleton component to show loading placeholders — it dramatically improves perceived performance.

  • Memo-ize heavy chart components: Wrap Recharts components in React.memo() so they don't re-render on unrelated state changes, especially in dashboards with frequent live data updates.

  • Keep sidebar items data-driven: Define nav items as a typed array (as shown above) rather than hardcoding JSX. This makes adding, removing, or reordering items trivial.

  • Use Radix UI's Tooltip for icon-only buttons: When your sidebar is collapsed to icon-only mode, always wrap icons in Shadcn's Tooltip so users still know what each button does.


📌 Key Takeaways

  • Tailwind CSS + Shadcn UI is the dominant stack for building production dashboards in 2026, offering design velocity without sacrificing flexibility or accessibility.

  • Route groups in Next.js App Router ((auth) and (dashboard)) let you cleanly separate authentication and dashboard layouts without affecting URLs.

  • Dark mode should always be implemented with next-themes and Shadcn's CSS variable system — hardcoded colors break dark mode and create maintenance nightmares.

  • Responsive design requires separate layout considerations for mobile (Sheet/Drawer) and desktop (fixed Sidebar) — don't just hide content, restructure the UX.

  • Shadcn components live in your codebase, not in node_modules — this is a feature, not a bug. It means you have full control and can customize without fighting abstractions.

  • Recharts integrates seamlessly with Tailwind's CSS variables, letting charts automatically respect dark/light mode without any extra configuration.

  • TypeScript should be used strictly throughout — define interfaces for all component props, API responses, and data models.


🏁 Conclusion

You've now built the foundation for a production-ready dashboard: a clean auth flow, persistent dark mode, a responsive layout with a collapsible sidebar, stat cards, and a live chart. This architecture scales cleanly — adding new pages is as simple as creating a new folder inside (dashboard) and adding a nav item to the array.

The Tailwind + Shadcn stack shines because it eliminates the trade-off between speed and control. You get pre-built, accessible components as a starting point, with the freedom to take them anywhere you need.

From here, natural next steps include:

  • Integrating a real backend (Next.js API routes, tRPC, or a dedicated server)
  • Adding role-based access control (RBAC) to protect specific dashboard routes
  • Implementing real-time data with WebSockets or server-sent events
  • Adding data tables with filtering, sorting, and pagination using TanStack Table

Happy building! 🛠️


📚 References

All Articles
Tailwind CSSShadcn UIReactDashboardDark ModeAuthenticationTypeScriptNext.jsUI ComponentsFrontend

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.