WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready
  1. Blog
  2. Build it: screens and apps
  3. Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready
Build it: screens and apps
Build it: screens and apps

Nextjs Admin Dashboard Tutorial: 5 Screens, Phone-Ready

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 19 min read

On this page

0%
  1. What does a Next.js admin dashboard need?
  2. How should you structure the App Router for an admin dashboard?
  3. How do you build the app shell for a Next.js admin dashboard?
  4. How do you build the dashboard home with KPIs?
    1. When should you use the KPI Dashboard instead?
    2. Can you render KPI cards from a server component?
  5. How do you build list pages with a server-side data table?
  6. How do you build a settings page that never loses input?
  7. How do you build the team and roles page?
  8. How do you handle loading and error states in the admin?
  9. How do you make every admin screen usable on a phone?
  10. Should you start from a Next.js admin template or build it yourself?
  11. What should you build first?
  12. FAQ
    1. How should I structure a Next.js App Router project for an admin dashboard?
    2. Is there a free shadcn admin dashboard for Next.js?
    3. Should an admin data table filter on the server or in the browser?
    4. Where do auth checks go in a Next.js admin dashboard?
    5. How do I make an admin dashboard usable on a phone?
    6. Which Wingo UI admin components are free?

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.

Published Oct 9, 2026

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:

ScreenIts jobBuilt withOn a phone
ShellNavigation, search, the accountApp Shell with SidebarThe sidebar moves into a drawer, a tab bar takes the bottom edge
HomeWhat changed, what needs attentionAdmin Dashboard Page or KPI DashboardKPIs two per row, panels in one column
ListsFind, filter and act on recordsData Table in server modeRows become cards, menus become bottom sheets
SettingsProfile, account, billing, securitySettings PageA list that pushes each section in, like iOS Settings
TeamMembers, roles, audit logUsers Admin PageMember cards, one role at a time in the matrix
Loading and errorsEvery routeloading.tsx, error.tsx and each block's loading propSkeletons 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 (opens in a new tab), 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.

text
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:

  1. Pages fetch, views render. Each page.tsx is 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.tsx next to the page.
  2. Auth lives in the data access layer. Layouts do not re-render on navigation (opens in a new tab), so a layout check misses route changes. Call verifySession() from a server-only module in every page and every Server Action; the data security guide (opens in a new tab) says to treat each Server Action as reachable by a direct POST request.
  3. 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:

tsx
// 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:

tsx
// 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:

tsx
// 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 (opens in a new tab). 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 (opens in a new tab) 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:

tsx
// 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:

tsx
// 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.

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
$ npx wingo-ui@latest add admin-dashboard-page
ProAdmin Dashboard Page docs

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:

tsx
// 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:

ts
// 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));
return {
page: Math.max(1, Number(one(params.page)) || 1),
pageSize: [10, 25, 50, 100].includes(size) ? size : 25,
sorting: sortId ? [{ id: sortId, desc: dir === "desc" }] : [],
globalFilter: one(params.q),
columnFilters: status ? [{ id: "status", value: status.split(",") }] : [],
};
}
export function writeQuery(query: DataTableQuery): string {
const params = new URLSearchParams();
if (query.page > 1) params.set("page", String(query.page));
if (query.pageSize !== 25) params.set("size", String(query.pageSize));
if (query.globalFilter) params.set("q", query.globalFilter);
const sort = query.sorting[0];
if (sort) params.set("sort", `${sort.id}.${sort.desc ? "desc" : "asc"}`);
const status = query.columnFilters.find((filter) => filter.id === "status")?.value;
if (Array.isArray(status) && status.length) params.set("status", status.join(","));
return params.toString();
}

The page parses the URL and queries one page:

tsx
// app/(admin)/clients/page.tsx
import { listClients } from "@/server/clients";
import { ClientsTable } from "./clients-table";
import { readQuery } from "./query";
type Search = Promise<Record<string, string | string[] | undefined>>;
export default async function ClientsPage({ searchParams }: { searchParams: Search }) {
const query = readQuery(await searchParams);
// checks the session, whitelists sort ids and filter values before building SQL
const { rows, total } = await listClients(query);
return <ClientsTable rows={rows} total={total} initial={query} />;
}

The view starts from the URL and writes changes back in a transition, so isPending drives the refreshing bar:

tsx
// app/(admin)/clients/clients-table.tsx
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { DataTable, createColumns, type DataTableQuery } from "@/components/ui/data-table";
import { writeQuery } from "./query";
export type ClientRow = { id: string; name: string; city: string; status: string; balance: number; lastInvoice: string | null };
const STATUSES = ["Active", "Overdue", "Prospect"];
const col = createColumns<ClientRow>();
const columns = col([
col.accessor("name", { header: "Client", mobile: "title" }),
col.accessor("city", { header: "City", mobile: "subtitle" }),
col.accessor("status", {
header: "Status",
type: "badge",
badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
filter: "multi-select",
// server mode only sees one page, so list every option yourself
filterOptions: STATUSES.map((status) => ({ value: status, label: status })),
mobile: "meta",
}),
col.accessor("balance", { header: "Balance", type: "currency" }),
col.accessor("lastInvoice", { header: "Last invoice", type: "relative" }),
]);
type Props = { rows: ClientRow[]; total: number; initial: DataTableQuery; loading?: boolean };
export function ClientsTable({ rows, total, initial, loading }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<DataTable
data={rows}
columns={columns}
mode="server"
rowCount={total}
defaultPage={initial.page}
defaultPageSize={initial.pageSize}
defaultSorting={initial.sorting}
defaultGlobalFilter={initial.globalFilter}
defaultColumnFilters={initial.columnFilters}
onQueryChange={(query) => {
const search = writeQuery(query);
startTransition(() => router.replace(search ? `/clients?${search}` : "/clients", { scroll: false }));
}}
refreshing={pending}
loading={loading}
quickFilters={[{ id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } }]}
onRowClick={(row) => router.push(`/clients/${row.id}`)}
caption="Clients"
locale="en-US"
/>
);
}

Details that save debugging time: onQueryChange does not fire on the first render (the server already sent that page), the search arrives debounced (300ms), and the page resets to 1 when the search, a filter or the sort changes. The table is uncontrolled (default* props), so the search box never lags behind a server round trip. The trade-off: the back button changes the data while the toolbar keeps the last query. If your users live on the back button, control page, sorting, globalFilter and columnFilters from the URL instead.

Search, sort or click the Overdue chip: in server mode the table hands your code one query object, printed under the pager. Below 768px every row is a card you can long press to select
$ npx wingo-ui@latest add data-table
ProData Table docs

On a phone the columns become cards: mobile: "title" is the heading, subtitle the line under it, meta the badge in the corner. Sorting, filters and the row menu open as bottom sheets, and with selectionMode on, a long press starts selection. Server mode keeps a compact pager on phones; client mode uses Load more.

Server mode is not always the right call. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch to server mode past a few thousand rows, or when permissions decide which rows exist for a user. To build the table yourself on TanStack Table instead, with the same server pagination, follow our TanStack Table v9 data table tutorial.

How do you build a settings page that never loses input?

Let one block own the drafts, and save through Server Actions that return their errors. The Settings Page has six sections (profile, account, notifications, billing, security, danger zone). A text change slides up a save bar with Discard and Save (or Mod+S), switches save on their own, a failed save keeps the draft and shows the message, and leaving a section with unsaved changes asks first.

Next.js recommends modeling expected errors as return values (opens in a new tab) in Server Functions, and in production the client does not get the original message of a server error, so a thrown "This workspace address is taken" would arrive as a generic error:

ts
// app/(admin)/settings/actions.ts
"use server";
import { verifySession } from "@/server/dal";
import { saveSettings } from "@/server/settings";
export type ActionResult = { error?: string };
export async function saveSection(section: string, values: unknown): Promise<ActionResult> {
const { userId } = await verifySession();
// validates with zod and returns { error } for anything the user can fix
return saveSettings(userId, section, values);
}
// savePreference(preference, value) has the same shape. verifyEmailCode(email, code),
// verifyTwoFactorCode(code) and checkWorkspaceAddress(slug) check the session too
// and resolve to a boolean (true: the code is right, the address is free)

The view turns a returned error into the rejection the block listens for, and replaces the block's demo checks with real ones:

tsx
// app/(admin)/settings/settings-view.tsx
"use client";
import { SettingsPage, type SettingsPageProps } from "@/components/blocks/settings-page";
import {
checkWorkspaceAddress,
savePreference,
saveSection,
verifyEmailCode,
verifyTwoFactorCode,
type ActionResult,
} from "./actions";
type Props = Pick<
SettingsPageProps,
| "profile"
| "account"
| "notifications"
| "billing"
| "plans"
| "sessions"
| "passwordChangedAt"
| "defaultTwoFactorEnabled"
| "twoFactorSecret"
| "twoFactorUri"
| "backupCodes"
>;
const LABELS = { codeHint: "Enter the 6-digit code." };
async function check(result: Promise<ActionResult>) {
const { error } = await result;
if (error) throw new Error(error);
}
export function SettingsView(props: Props) {
return (
<SettingsPage
{...props}
onSave={(section, values) => check(saveSection(section, values))}
onPreferenceChange={(preference, value) => check(savePreference(preference, value))}
verifyEmailCode={verifyEmailCode}
verifyTwoFactorCode={verifyTwoFactorCode}
checkWorkspaceAddress={checkWorkspaceAddress}
labels={LABELS}
/>
);
}

Pass every data prop from the page: anything you leave out falls back to the block's sample data. The security defaults are demo values on purpose. Without verifyEmailCode and verifyTwoFactorCode, the code 123456 passes; without twoFactorSecret, twoFactorUri and backupCodes, the setup dialog shows sample ones; and the default codeHint label reads "In this demo, the code is 123456." Generate the secret and backup codes on the server for the signed-in user.

The dialogs take handlers with the same promise contract (onEmailChange, onPasswordChange, onTwoFactorChange, onSessionRevoke, onPlanChange, onDeleteAccount). One naming detail: the section ids are internal (profil, cont, notificari, facturare, securitate, pericol), so map them to your own segments if each section gets a URL through onSectionChange.

Open Profile, edit the full name and press Save. This demo drops the first save on purpose: the bar shows the error and your edit stays, and the next Save goes through. In a frame this narrow the sections are a list that pushes each one in
$ npx wingo-ui@latest add settings-page
ProSettings Page docs

From 768px the sections are a column on the left (or tabs with nav="tabs"). On a phone each section pushes in with a back button and a swipe back, dialogs open as bottom sheets, and the save bar is pinned above the tab bar. The block validates each form before it calls onSave: invalid fields turn red and the first one takes focus, the pattern our React form components guide covers field by field.

How do you build the team and roles page?

Treat every control on it as a request the server can refuse. The Users Admin Page has three tabs: Members (the team and pending invitations against your seats, a role picker per row), Roles and permissions (a matrix with a save bar) and Audit log (a filterable history with CSV export). The server decides who may manage and enforces the rules the interface shows:

ts
// app/(admin)/team/actions.ts
"use server";
import { requireAdmin } from "@/server/dal";
import { changeMemberRole } from "@/server/team";
export async function changeRole(memberId: string, role: string): Promise<{ error?: string }> {
const admin = await requireAdmin();
// refuses to demote the last administrator, the same rule the picker shows
return changeMemberRole(admin.workspaceId, memberId, role);
}
// invite, removeMember and savePermissions follow the same shape
tsx
// app/(admin)/team/team-view.tsx
"use client";
import { UsersAdminPage, type UsersAdminPageProps } from "@/components/blocks/users-admin-page";
import { changeRole, invite, removeMember, savePermissions } from "./actions";
type Props = Pick<
UsersAdminPageProps,
"companyName" | "members" | "invites" | "roles" | "permissionRoles" | "permissionGroups" | "defaultPermissions" | "auditEvents" | "seats" | "canManage"
>;
async function check(result: Promise<{ error?: string }>) {
const { error } = await result;
if (error) throw new Error(error);
}
export function TeamView(props: Props) {
return (
<UsersAdminPage
{...props}
onInvite={(input) => check(invite(input))}
onRoleChange={(member, role) => check(changeRole(member.id, role))}
onRemove={(member) => check(removeMember(member.id))}
onPermissionsSave={(value) => check(savePermissions(value))}
/>
);
}

The page computes canManage from the session and passes it with the data. With canManage={false} the block turns read-only and says why, which beats hiding the page from people who only need to see who has access.

The interface guards the obvious mistakes: the last administrator cannot be demoted, nobody removes themselves or the owner, and invitations hold a seat. Those guards are for the person clicking; your actions enforce them again, because a Server Action cannot tell which button sent the request. Likewise the block adds this page's changes to the top of the audit log as feedback, but the real audit rows are the ones your server writes in the same transaction.

On a phone, members become cards with the role picker inside, the invite dialog is a bottom sheet, and the matrix shows one role at a time with chips, because five role columns do not fit 390px. The parts also ship alone: Members Table, Permissions Matrix and Audit Log.

How do you handle loading and error states in the admin?

Give every route a loading.tsx that renders the same component with its loading prop, and the group one error.tsx. loading.js (opens in a new tab) shows its fallback immediately on navigation while the shell stays interactive, and a skeleton in the real layout means nothing jumps when the data arrives:

tsx
// app/(admin)/clients/loading.tsx
import { ClientsTable } from "./clients-table";
import { readQuery } from "./query";
export default function Loading() {
return <ClientsTable rows={[]} total={0} initial={readQuery({})} loading />;
}

The Data Table draws skeleton rows in its column layout (cards on phones), and the dashboard, settings and team blocks have the same prop. A route without its own loading.tsx falls back to the nearest parent's, so one group-level skeleton would flash the same layout before every page.

For errors, Next.js 16.3 made the retry prop of error.js (opens in a new tab) stable; it re-fetches and re-renders the segment:

tsx
// app/(admin)/error.tsx
"use client";
import { Button } from "@/components/ui/button";
export default function AdminError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
return (
<div role="alert" className="mx-auto flex max-w-md flex-col items-center gap-3 px-4 py-16 text-center">
<h2 className="text-lg font-semibold">This page did not load</h2>
<p className="text-sm text-muted-foreground">If it keeps failing, send us this reference: {error.digest ?? "none"}</p>
<Button onClick={retry}>Try again</Button>
</div>
);
}

Empty states belong in the components: the Data Table's emptyState for a list with no rows yet and noResultsState when filters match nothing, so "No clients yet" and "Nothing matches" never get mixed up.

How do you make every admin screen usable on a phone?

Change behavior as well as layout, and check each screen at 390px on a real phone. People open a back office on phones to approve an expense, check an order or answer a client. The checklist we hold every admin screen to:

  • Navigation: everything in a drawer behind the menu button, plus a tab bar for the three to five daily destinations; tablets in portrait get the phone layout.
  • One bar on the bottom edge: the tab bar pads the home indicator and publishes its height as --bottom-bar-offset, and the settings and team save bars read it to sit above the tab bar.
  • Numbers: KPIs two per row, with values that shrink to fit 360px instead of wrapping.
  • Tables: rows become cards (title, subtitle, badge, up to four fields); nobody should scroll sideways to read one record.
  • Overlays: menus, filters, date pickers and detail views open as bottom sheets you can drag down; confirmations stay centered.
  • Container queries: inside a shell, measure the container (as the KPI grid does), so a sidebar never squeezes a layout built for the viewport.
  • Touch: 44px targets on coarse pointers, no hover-only actions, and every gesture backed by a visible button.

Our mobile-first React components guide covers the bottom sheets, touch targets, safe areas and phone tables in depth, with code, and the React bottom navigation bar guide covers the tab bar and when the sidebar alone is enough.

Should you start from a Next.js admin template or build it yourself?

Start from a free starter to learn the App Router or when you already use its stack; build from blocks when settings, roles and phone layouts must work on day one. Before you pick a "nextjs admin template" or a "shadcn admin dashboard", compare these free starting points with the blocks in this guide (as of October 2026):

OptionPrice and licenseWhat you getWhat is left to you
Next.js Learn dashboard course (opens in a new tab)FreeA financial dashboard with Postgres, Server Actions, search and pagination in the URL, NextAuth.js and Proxy; its invoices table turns into cards on phonesSettings, team and roles; it teaches the patterns and leaves the screens to you
shadcn/ui dashboard-01 block (opens in a new tab)Free, MIT (opens in a new tab)Inset sidebar, summary cards, an interactive area chart and a TanStack data table (npx shadcn add dashboard-01)Settings, team pages, auth; the table keeps its rows on a phone and scrolls sideways
next-shadcn-dashboard-starter (opens in a new tab)Free, MITNext.js 16, shadcn/ui on Base UI, Clerk auth, product and user tables with URL state, team, billing and profile pagesTeam, roles and billing come from Clerk's prebuilt components, so they need Clerk
Wingo UI blocks (this guide)Pro: $8 a month, $80 a year or $150 lifetimeApp Shell, Admin Shell, the two dashboards, Data Table, Settings Page and Users Admin Page, all with phone layoutsYour data layer and auth; the blocks are UI only

Where the free options win: to learn, take the Next.js course, which teaches the data and auth side this guide skips. If you run shadcn/ui and need a dashboard tonight, dashboard-01 is one command and you own the code, which makes it the shortest path if you searched for "nextjs admin dashboard shadcn"; for desk-only internal tools a sideways-scrolling table is acceptable. If you already use Clerk, the Kiranism starter wires organizations, team management and billing through Clerk's prebuilt components. Wingo UI earns its price when phone users matter and you do not want to build the settings, team and table behaviors yourself. For libraries in general, our shadcn alternatives comparison covers fourteen with dated prices.

What should you build first?

Build the shell first, then one list page, then the home. The shell sets the navigation and phone layout every screen inherits, a list page exercises the server path (URL, query, Server Actions) that settings and team reuse, and the home is easiest once real data exists.

The admin blocks in this guide are part of Wingo UI Pro. With it, start with npx wingo-ui@latest add app-shell (it brings the Sidebar, Topbar, Bottom Tab Bar and the hooks along) and drop it into app/(admin)/layout.tsx as above. The free basics they build on, such as the Button, Card, Badge, Dialog and Toast, install without an account. More screen-by-screen tutorials live in Build it: screens and apps.

Components in this post

  • App Shell

    The whole admin layout in one component: a collapsible, resizable sidebar, a top bar and the page; a drawer and a tab bar on phones.

    Pro
  • Admin Dashboard Page

    The home of a back office: greeting and period, four KPIs, revenue against the previous period, recent orders, alerts and team activity.

    Pro
  • KPI Dashboard

    The first screen of a back office: KPI tiles with deltas, revenue against the last period, sources, funnel, top sellers, invoices, activity.

    Pro
  • Data Table

    The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

    Pro
  • Sidebar

    The admin sidebar: grouped links with icons, nested sections, badges, a sliding active pill, an icon rail with flyouts and a filter.

    Pro
  • Stat Card

    The KPI tile of a dashboard: a label, a big number that counts up, a delta pill that knows good from bad, a sparkline and a goal bar.

    Pro
  • Settings Page

    The account settings of a back office: profile, account, notifications, billing, security and a danger zone, with a save bar.

    Pro
  • Users Admin Page

    The team page of a back office: members and invitations with seats, roles with a permissions matrix and a save bar, and the audit log.

    Pro

FAQ

How should I structure a Next.js App Router project for an admin dashboard?

Put every admin route in a route group such as app/(admin) whose layout renders the shell, and keep sign-in in a separate group without it. Pages are server components that fetch through a server-only data access layer, and each route gets its own loading.tsx.

Is there a free shadcn admin dashboard for Next.js?

Yes. As of October 2026 shadcn/ui ships a free dashboard-01 block (npx shadcn add dashboard-01) with a sidebar, summary cards, a chart and a data table, but settings, roles and a phone layout for the table are left to you. The MIT next-shadcn-dashboard-starter adds Clerk auth, product and user tables, and team, billing and profile pages built on Clerk's prebuilt components.

Should an admin data table filter on the server or in the browser?

Filter in the browser when one request can load every row the user may see; it is instant and needs no query API. Filter on the server when the data grows past a few thousand rows or permissions decide which rows a user sees, and keep the page, sort, search and filters in the URL.

Where do auth checks go in a Next.js admin dashboard?

In a server-only data access layer that every page and every Server Action calls. A check in the layout or in proxy.ts alone is too weak: layouts do not re-render on client navigation, and Server Actions can be called with a direct POST request.

How do I make an admin dashboard usable on a phone?

Move the sidebar into a drawer with a bottom tab bar, show KPIs two per row, turn table rows into cards, open menus and detail views as bottom sheets, and keep a single bar on the bottom edge above the safe area. Test at 390px on a real phone, because a narrowed desktop window has no touch and no on-screen keyboard.

Which Wingo UI admin components are free?

None of the admin blocks in this guide: the App Shell, Sidebar, Data Table, Stat Card, KPI Dashboard, Admin Dashboard Page, Settings Page and Users Admin Page are Pro. The free basics, such as Button, Card, Badge, Dialog, Select, Tabs and Toast, install with the CLI without an account.

  • Next.js
  • Admin dashboard
  • App Router
  • Data table
  • Mobile UI
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

React Bottom Navigation Bar in Next.js: Safe Areas, Badges

Build a React bottom navigation bar in Next.js: 3 to 5 link tabs, safe areas, badges screen readers announce, hide on scroll, and when a sidebar fits better.

SRSerban Rusu·Oct 9, 2026·8 min read
Mobile UI in React

100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind

The 100vh mobile bug explained: why h-screen hides your bottom bar, when to use dvh, svh or lvh in Tailwind, and how to keep a bar above the keyboard.

SRSerban Rusu·Oct 9, 2026·11 min read
Comparisons and alternatives

14 shadcn Alternatives in 2026: Price, Mobile and MCP

14 shadcn alternatives checked in October 2026: free and paid UI libraries by license, price, mobile behavior and MCP support, with a pick per use case.

SRSerban Rusu·Oct 9, 2026·19 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI