TL;DR
Build a Next.js admin dashboard as five screens in one App Router route group: an app shell layout, a home page with KPIs, list pages with a server-side data table whose state lives in the URL, a settings page and a team page with roles. Pages fetch through a server-only data access layer, and every change goes through a Server Action that checks the session itself. On phones the sidebar becomes a drawer with a bottom tab bar, table rows become cards and menus open as bottom sheets.
Most admin dashboard tutorials stop at a sidebar, four stat cards, a chart and a table that scrolls sideways on a phone. A back office needs more: list pages that filter on the server, a settings page that keeps what someone typed when a save fails, a team page with roles, loading and error states on every route, and a layout that works when the person approving an invoice is on a phone. This nextjs admin dashboard tutorial builds all of it on the Next.js App Router with React 19 and Tailwind CSS v4, screen by screen, with the code. We build Wingo UI and the examples use our blocks, so we are not neutral; the App Router patterns work with any component library, and the last section compares the free starters.
What does a Next.js admin dashboard need?
Five screens and two states: a shell around a home page, list pages, a settings page and a team page, each with a loading and an error state. The React admin dashboard components we use for each, and their phone behavior:
| Screen | Its job | Built with | On a phone |
|---|---|---|---|
| Shell | Navigation, search, the account | App Shell with Sidebar | The sidebar moves into a drawer, a tab bar takes the bottom edge |
| Home | What changed, what needs attention | Admin Dashboard Page or KPI Dashboard | KPIs two per row, panels in one column |
| Lists | Find, filter and act on records | Data Table in server mode | Rows become cards, menus become bottom sheets |
| Settings | Profile, account, billing, security | Settings Page | A list that pushes each section in, like iOS Settings |
| Team | Members, roles, audit log | Users Admin Page | Member cards, one role at a time in the matrix |
| Loading and errors | Every route | loading.tsx, error.tsx and each block's loading prop | Skeletons in the phone layout |
How should you structure the App Router for an admin dashboard?
Put every admin route in one route group with its own layout. A folder in parentheses does not appear in the URL, so app/(admin)/clients/page.tsx serves /clients while the group's layout.tsx wraps it in the shell. Sign-in lives in its own group and never sees the sidebar.
app/ layout.tsx root: html, body, <Providers> providers.tsx LinkProvider (next/link) and the Toaster (auth)/ sign-in/page.tsx no shell (admin)/ layout.tsx the shell: AppShell, Sidebar, Topbar nav.tsx sidebar groups and phone tabs error.tsx the admin error boundary page.tsx /: the home with KPIs dashboard-home.tsx actions.ts order actions for the home clients/ page.tsx reads the URL, fetches one page clients-table.tsx query.ts loading.tsx [id]/page.tsx one client settings/ page.tsx settings-view.tsx actions.ts team/ page.tsx team-view.tsx actions.ts server/ dal.ts "server-only": verifySession, getUser, requireAdmin Three rules hold it together:
- Pages fetch, views render. Each
page.tsxis a server component that loads data. The blocks are client components, so function props (a save handler, a router call) live in a small*-view.tsxnext to the page. - Auth lives in the data access layer. Layouts do not re-render on navigation, so a layout check misses route changes. Call
verifySession()from aserver-onlymodule in every page and every Server Action; the data security guide says to treat each Server Action as reachable by a direct POST request. - The URL holds the view. Period, filters, page, open tab: if it changes what the screen shows, it goes in the search params, so a link reproduces the screen.
How do you build the app shell for a Next.js admin dashboard?
Render one AppShell in the group's layout with a Sidebar and a Topbar, and read the sidebar's cookie on the server so the first paint is right. The shell shows the sidebar beside the page on desktop, and a drawer plus a bottom tab bar on phones. It switches with CSS, so the server HTML is already right.
First, the router wiring: the Wingo UI navigation links render through LinkProvider, so one client provider turns them all into next/link:
// app/providers.tsx "use client"; import type { ReactNode } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Toaster } from "@/components/ui/toast"; import { LinkProvider } from "@/lib/navigation"; export function Providers({ children }: { children: ReactNode }) { const router = useRouter(); return ( <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}> {children} {/* phones have a tab bar at the bottom, so toasts drop from the top */} <Toaster mobilePosition="top" /> </LinkProvider> ); } The sidebar and the tab bar share the NavItem shape, so one file feeds both:
// app/(admin)/nav.tsx import { House, Settings, ShieldCheck, Users } from "lucide-react"; import type { SidebarNavGroup } from "@/components/ui/sidebar"; import type { NavItem } from "@/lib/navigation"; export const nav: SidebarNavGroup[] = [ { items: [{ label: "Home", href: "/", icon: <House /> }] }, { label: "Sales", items: [{ label: "Clients", href: "/clients", icon: <Users />, badge: 3 }] }, { label: "Workspace", items: [ { label: "Team", href: "/team", icon: <ShieldCheck /> }, { label: "Settings", href: "/settings", icon: <Settings /> }, ], }, ]; export const tabs: NavItem[] = [ { label: "Home", href: "/", icon: <House />, match: "exact" }, { label: "Clients", href: "/clients", icon: <Users /> }, { label: "Team", href: "/team", icon: <ShieldCheck /> }, { label: "Settings", href: "/settings", icon: <Settings /> }, ]; And the layout, a server component:
// app/(admin)/layout.tsx import type { ReactNode } from "react"; import { cookies } from "next/headers"; import { AppShell } from "@/components/ui/app-shell"; import { Sidebar } from "@/components/ui/sidebar"; import { Topbar } from "@/components/ui/topbar"; import { getUser } from "@/server/dal"; import { nav, tabs } from "./nav"; export default async function AdminLayout({ children }: { children: ReactNode }) { const [store, user] = await Promise.all([cookies(), getUser()]); // the shell writes `${persistKey}-collapsed` ("app-shell" by default) as "true" or "false" const collapsed = store.get("app-shell-collapsed")?.value === "true"; return ( <AppShell defaultCollapsed={collapsed} mobileBreakpoint="lg" sidebar={<Sidebar groups={nav} filter user={{ name: user.name, email: user.email, avatar: user.avatarUrl }} />} topbar={<Topbar title="Northwind" />} tabs={tabs} > {children} </AppShell> ); } Mod+B (Cmd on a Mac, Ctrl elsewhere) folds the sidebar into a 64px icon rail and stores that in the app-shell-collapsed cookie. Without the server read, a user who collapsed it sees it expanded on every first paint and then snap to the rail after hydration. Use the literal cookie name on the server: the persistedCookieName helper lives in a hook file that imports client-only React hooks, which a server layout cannot import. Below the breakpoint the sidebar becomes a drawer that closes on navigation, Escape, a tap outside or a swipe back, and focus returns to the menu button. A "Skip to content" link is the first Tab stop.
We pass mobileBreakpoint="lg" (1024px) on purpose. At md (768px), an 820px tablet in portrait keeps a 256px sidebar and 564px for the page, too little for a table. At lg it gets the phone layout and the full width.
Keep the awaits in this layout cheap: a slow await here holds every page behind it. A cookie read and a cached session lookup are fine; a dashboard query is not.
For comparison, the shadcn/ui Sidebar (as of October 2026) stores its open state in a sidebar_state cookie for seven days and renders a Sheet when its useIsMobile hook reports a phone. It has no bottom tab bar, so on a phone every destination is two taps away.
For the whole back office layout in one block, the Admin Shell adds a workspace switcher, a global search on /, notifications, a command menu on Mod+K and a five-tab bar whose More tab opens the drawer. Our React command palette guide covers that menu's shortcuts, nested pages and phone sheet.
How do you build the dashboard home with KPIs?
Fetch the numbers in the page for the period in the URL, and pass them to a dashboard block as props. The Admin Dashboard Page is the home of an orders business (four KPIs, revenue against the previous period, recent orders, what needs attention, team activity); the KPI Dashboard suits a B2B or analytics app.
The page reads the period, defaults to the last 30 days and fetches:
// app/(admin)/page.tsx import { isValid, parseISO, startOfDay, subDays } from "date-fns"; import { getDashboard } from "@/server/dashboard"; import { getUser } from "@/server/dal"; import { DashboardHome } from "./dashboard-home"; function readPeriod(from?: string, to?: string) { const today = startOfDay(new Date()); const end = to ? parseISO(to) : today; const start = from ? parseISO(from) : subDays(end, 29); return isValid(start) && isValid(end) ? { from: start, to: end } : { from: subDays(today, 29), to: today }; } export default async function HomePage({ searchParams }: { searchParams: Promise<{ from?: string; to?: string }> }) { const { from, to } = await searchParams; const period = readPeriod(from, to); // { kpis, revenue, orders, tasks, activity, topProducts } for the period const [user, data] = await Promise.all([getUser(), getDashboard(period)]); return <DashboardHome firstName={user.firstName} period={period} data={data} />; } The view writes a new period back to the URL and sends order actions to Server Actions:
// app/(admin)/dashboard-home.tsx "use client"; import { useRouter } from "next/navigation"; import { format } from "date-fns"; import { AdminDashboardPage } from "@/components/blocks/admin-dashboard-page"; import type { DashboardData } from "@/components/blocks/admin-dashboard-page-data"; import type { DateRange } from "@/components/ui/date-range-picker"; import { runOrderAction } from "./actions"; type Props = { firstName: string; period: DateRange; data: DashboardData }; export function DashboardHome({ firstName, period, data }: Props) { const router = useRouter(); return ( <AdminDashboardPage {...data} userName={firstName} defaultPeriod={period} onPeriodChange={({ from, to }) => { if (!from || !to) return; const params = new URLSearchParams({ from: format(from, "yyyy-MM-dd"), to: format(to, "yyyy-MM-dd") }); router.replace(`/?${params}`, { scroll: false }); }} onOrderAction={async (action, order) => { const { error } = await runOrderAction(action, order.id); if (error) throw new Error(error); }} /> ); } The period is uncontrolled on purpose (defaultPeriod): the date field updates the moment someone applies a range, and the panels follow when the server answers. onOrderAction is optimistic: the status changes at once, the row shows a spinner while the promise runs, and a rejection rolls it back with an error toast, which is why the handler throws on an error. One catch: after a cancel, the toast's Undo restores the row on screen only and calls none of your code, so the next server render shows the order as cancelled again. Rewrite labels.cancelDescription so users know a cancel is final; its default text promises an email and a refund, which only your server can keep.
Live demo: Admin Dashboard Page. The original article embeds an interactive demo here (Pick another period in the date field, then click an order in Recent orders to open its sheet. This frame is narrower than 1024px, so you see the phone layout: the sidebar sits behind the menu button and the tab bar holds the bottom edge). Try it live on wingo-ui.com or open the Admin Dashboard Page docs. Install:
npx wingo-ui@latest add admin-dashboard-page
On a phone the KPIs sit two by two, recent orders become cards with Load more, and the date field and the order details open as bottom sheets you drag down to close.
When should you use the KPI Dashboard instead?
When the home is about analytics more than orders: revenue over time, sales by source, a conversion funnel, top salespeople and invoices. It takes one data object and hides a widget whose data is missing, so it fits a company with no funnel. It has the controls an analytics page needs: onRefresh (a promise spins the button and announces the update), onExport with CSV and PDF, a widgets list to pick and order panels, and widgetErrors with onWidgetRetry to show an error and a Try again button inside the one widget whose query failed, so the rest of the page stays up.
Its grid uses container queries: one column, two from 672px and three from 1152px of its own width. Inside a shell that matters, because a 1280px window with a 256px sidebar leaves about 1000px, and viewport breakpoints would lay the grid out for space it does not have. The DashboardWidget and KpiDashboardGrid parts are exported for your own panels.
Can you render KPI cards from a server component?
Yes, as long as every prop is data. The Stat Card takes numbers, strings and icons, so a server component can render a row directly:
// app/(admin)/reports/page.tsx import { FileText, Timer, UserPlus, Wallet } from "lucide-react"; import { StatCard, StatGroup } from "@/components/ui/stat-card"; import { getKpis } from "@/server/reports"; export default async function ReportsPage() { const k = await getKpis(); return ( <StatGroup variant="joined"> <StatCard label="Revenue" icon={<Wallet />} value={k.revenue} format="currency" delta={k.revenueChange} deltaLabel="vs. last month" sparkline={k.dailyRevenue} goal={k.revenueGoal} locale="en-US" /> <StatCard label="Invoices issued" icon={<FileText />} value={k.invoices} delta={k.invoicesChange} deltaFormat="number" locale="en-US" /> <StatCard label="New clients" icon={<UserPlus />} value={k.newClients} delta={k.newClientsChange} locale="en-US" /> <StatCard label="Time to payment" icon={<Timer />} value={k.secondsToPay} format="duration" delta={k.timeToPayChange} invertTrend locale="en-US" /> </StatGroup> ); } The server HTML holds the final numbers, the count-up runs on the client when a card scrolls into view, and screen readers hear one sentence with the final value and the change. invertTrend makes a drop in time to payment green. Two limits: a function prop such as formatDelta needs a client file, and href renders a plain anchor (a full page load in Next.js), so linked tiles need a client wrapper whose onClick calls event.preventDefault() and then router.push.
How do you build list pages with a server-side data table?
Run the Data Table in server mode, keep its query in the URL, and let the page fetch one page at a time. In mode="server" the table does no sorting, filtering or paging itself: data is the current page, rowCount the total, and every change arrives in onQueryChange as one object (page, pageSize, sorting, globalFilter, columnFilters). One module translates it to and from the search params:
// app/(admin)/clients/query.ts import type { DataTableQuery } from "@/components/ui/data-table"; type Params = Record<string, string | string[] | undefined>; const one = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value) ?? ""; export function readQuery(params: Params): DataTableQuery { const [sortId, dir] = one(params.sort).split("."); const status = one(params.status); const size = Number(one(params.size)); r...