How to Build an Admin Dashboard with Next.js 16 and Tailwind CSS v4
A working admin dashboard in Next.js 16 and Tailwind CSS v4 — App Router layouts, a CSS-first theme, an accessible sidebar, and the server/client split that keeps it fast.
Building an admin dashboard in Next.js 16 and Tailwind CSS v4 comes down to four decisions: a CSS-first theme instead of a JavaScript config, a shared App Router layout that renders the shell once, a deliberate server/client split so interactivity is opt-in, and a sidebar that works from the keyboard. Get those right and the rest is screens.
This is the architecture we use across our own admin templates. Everything below is code you can paste into a fresh project.
What you are building
A dashboard shell with a persistent sidebar, a top bar, and a content area that swaps per route — the layout underneath almost every internal tool. By the end you will have:
- A Next.js 16 project using the App Router
- A Tailwind v4 theme defined entirely in CSS
- A layout that renders the shell once and never re-renders it on navigation
- An accessible, collapsible sidebar
- A data-fetching pattern that keeps the JavaScript bundle small
1. Create the project
npx create-next-app@latest my-dashboard --typescript --app --tailwind
cd my-dashboard
That scaffolds Next.js 16 with the App Router and Tailwind v4 already wired through @tailwindcss/postcss. Confirm your postcss.config.mjs looks like this — v4 uses a dedicated PostCSS package rather than the tailwindcss plugin v3 used:
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
2. Define the theme in CSS, not JavaScript
The biggest change in Tailwind v4 is that tailwind.config.js is gone. Design tokens live in a @theme block in your stylesheet, and every token automatically generates the matching utilities.
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: var(--font-outfit), sans-serif;
--color-primary: #465fff;
--color-primary-50: #ecf3ff;
--color-primary-500: #465fff;
--color-primary-600: #3641f5;
--color-primary-950: #161950;
--color-gray-25: #fcfcfd;
--color-gray-100: #f2f4f7;
--color-gray-800: #1d2939;
--color-gray-900: #101828;
}
Declaring --color-primary-600 generates bg-primary-600, text-primary-600, border-primary-600 and the rest, with no config file and no safelist.
The @custom-variant dark line is what makes dark: respond to a .dark class on html rather than the OS setting. That matters for dashboards, where users expect an in-app theme toggle that overrides their system preference.
One sharp edge worth knowing before you lose an hour to it: Turbopack does not hot-reload
@themechanges. Add a new color token, andbg-brandwill silently produce no CSS until you restart the dev server. It is not a typo on your end.
If you are moving an existing project across, the Tailwind v4 migration guide covers what breaks and the order to fix it in.
3. Render the shell once with a nested layout
The App Router's key advantage for dashboards is that a layout.tsx persists across navigation. The sidebar does not unmount, does not re-fetch, and does not lose its scroll position when a user clicks between pages.
Put the dashboard shell in a route group so it wraps every internal page without appearing in the URL:
app/
(dashboard)/
layout.tsx → sidebar + topbar shell
dashboard/page.tsx
orders/page.tsx
settings/page.tsx
(marketing)/
page.tsx → public landing page, no shell
// app/(dashboard)/layout.tsx
import Sidebar from "@/components/Sidebar";
import Topbar from "@/components/Topbar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen bg-gray-25 dark:bg-gray-900">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main id="main" className="flex-1 p-6">
{children}
</main>
</div>
</div>
);
}
min-w-0 on the content column is not decoration. Without it, a wide table inside a flex child refuses to shrink and pushes the whole page into horizontal scroll — the single most common dashboard layout bug.
4. Keep the server/client boundary tight
Every component in the App Router is a Server Component until you write "use client". Each "use client" you add ships that component and its imports to the browser.
The mistake that costs the most is marking the layout as a client component because the sidebar needs a toggle. That drags the entire shell into the bundle. Instead, isolate the interactive part:
// components/Sidebar.tsx — Server Component, no directive
import SidebarToggle from "./SidebarToggle";
import { navItems } from "@/data/nav";
export default function Sidebar() {
return (
<aside className="w-64 border-r border-gray-100 dark:border-gray-800">
<SidebarToggle />
<nav aria-label="Dashboard">
<ul>
{navItems.map((item) => (
<li key={item.href}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
</nav>
</aside>
);
}
Only SidebarToggle carries "use client". The navigation markup, which is the bulk of the DOM, renders on the server and ships as HTML.
A useful rule: if a component does not use state, effects, refs, or browser event handlers, it does not need to be a client component. Push the boundary as far down the tree as it will go.
5. Fetch data in the page, not the component
Server Components can be async, which removes the loading-state ceremony entirely:
// app/(dashboard)/orders/page.tsx
import { getOrders } from "@/lib/orders";
import OrdersTable from "@/components/OrdersTable";
export default async function OrdersPage() {
const orders = await getOrders();
return <OrdersTable orders={orders} />;
}
No useEffect, no isLoading, no client-side fetch waterfall. The data is in the HTML on first paint.
Pair it with a loading.tsx beside the page and Next.js streams a skeleton while the query runs:
// app/(dashboard)/orders/loading.tsx
export default function Loading() {
return <div className="h-64 animate-pulse rounded-2xl bg-gray-100" />;
}
6. Make the sidebar keyboard-accessible
Dashboards are used all day, often by people who navigate by keyboard. Three things get missed most often:
- Label the nav.
aria-label="Dashboard"on thenavelement distinguishes it from the top bar's navigation for screen reader users. - Mark the current page.
aria-current="page"on the active link, not just a background color. Color alone is invisible to assistive tech. - Manage focus on the mobile drawer. When the drawer opens, focus moves into it;
Escapecloses it and returns focus to the trigger. Without that, keyboard users tab into a drawer they cannot leave.
"use client";
import { useEffect } from "react";
export function useDrawer(open: boolean, onClose: () => void) {
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previous;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
}
7. Add dark mode without the flash
With @custom-variant dark already in place, dark mode is a class toggle on html. The part that bites is the flash of the wrong theme on first paint — a rendering-order problem that CSS cannot solve. We wrote up the blocking-script fix separately, because it is the single most-searched Next.js theming question and it deserves its own walkthrough.
Mistakes worth avoiding
| Mistake | What happens | Fix |
|---|---|---|
"use client" on the layout | Whole shell ships to the browser | Isolate the interactive leaf |
Missing min-w-0 on flex children | Wide tables force page-wide scroll | Add min-w-0 to the content column |
Fetching in useEffect | Client waterfall, empty first paint | await in an async Server Component |
Editing @theme and not restarting | New utilities silently missing | Restart the dev server |
| Color-only active state | Invisible to screen readers | Add aria-current="page" |
Frequently asked questions
Does Tailwind v4 still support a JavaScript config?
Yes, via @config "./tailwind.config.js" for incremental migration. New projects should use @theme — the JS config path exists for compatibility, not as the recommended approach.
Should the dashboard be statically rendered? Usually not. Authenticated dashboards read per-user data, so they render dynamically. Keep the marketing pages static and let the dashboard routes be dynamic; the App Router handles both in one app.
How many "use client" components is too many?
There is no fixed number — what matters is where they sit. One client component near a leaf is cheap. One at the root of your layout is not.
Where to go from here
The architecture above is the foundation. What takes real time afterwards is the volume: tables, forms, charts, modals, empty states, and the twenty screens nobody scopes for.
If you would rather start from a finished shell, our Next.js admin templates ship this exact structure, and ASoc Admin covers 13 dashboards across 135+ pages in React, Next.js, Vue and Angular editions.
