Skip to content
zedkit

Build an admin dashboard

AppShell, DataTable, a drawer form, ConfirmDialog, and theme tokens in a small Next.js admin screen.

Every UI snippet on this page is type-checked exactly as written.

Who this is for

You build internal admin tools in React and want one screen that combines layout, data table, side-panel editing, and destructive confirmation — using the same components as the live docs demos.

Prerequisites

  • Node.js 22+, a Next.js App Router app, React 19, Tailwind CSS v4.
  • Theme wired first: follow Theme an admin app (.dark class + kit globals).
  • Keyboard: tab through the table actions; the drawer traps focus until closed.

Steps

1. Install the UI kit

pnpm add @zedtech/zedkit-ui @fontsource-variable/inter @fontsource-variable/space-grotesk

2. Add the dashboard view

Create app/admin-dashboard-page.tsx (or colocate with your route) with the composed screen:

Maya Chen

Projects

Services in this workspace. Edit a row or remove it.

Actions
reports-api
active
billing-worker
paused
webhooks
active
search-index
paused
Admin dashboard
"use client";

import {
  AppShell,
  Badge,
  Button,
  ConfirmDialog,
  DataTable,
  Drawer,
  DrawerBody,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHandle,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
  Field,
  FieldLabel,
  Input,
  PageHeader,
  Stack,
  Text,
  createDataTableColumns,
} from "@zedtech/zedkit-ui";

interface ItemRow {
  name: string;
  status: string;
}

const columnHelper = createDataTableColumns<ItemRow>();

const seed: ItemRow[] = [
  { name: "reports-api", status: "active" },
  { name: "billing-worker", status: "paused" },
  { name: "webhooks", status: "active" },
  { name: "search-index", status: "paused" },
];

function Sidebar() {
  return (
    <Stack gap="lg" className="h-full surface-pad-compact">
      <Stack gap="tight">
        <Text className="font-semibold">Harbor Studio</Text>
        <Text variant="muted" className="text-caption">
          Admin
        </Text>
      </Stack>
      <nav aria-label="Main">
        <Stack gap="tight">
          <Button type="button" variant="secondary" className="w-full justify-start" aria-current="page">
            Projects
          </Button>
          <Button type="button" variant="ghost" className="w-full justify-start">
            Services
          </Button>
          <Button type="button" variant="ghost" className="w-full justify-start">
            Settings
          </Button>
        </Stack>
      </nav>
    </Stack>
  );
}

function TopBar() {
  return (
    <Stack direction="horizontal" gap="sm" className="justify-end surface-pad-inline surface-pad-block-sm">
      <Text
        as="span"
        aria-hidden="true"
        className="flex size-person items-center justify-center rounded-full bg-primary text-caption font-semibold text-primary-foreground"
      >
        MC
      </Text>
      <Text as="span" className="text-dense font-medium">
        Maya Chen
      </Text>
    </Stack>
  );
}

export function AdminDashboardPage() {
  const columns = [
    columnHelper.accessor("name", { header: "Name" }),
    columnHelper.accessor("status", {
      header: "Status",
      cell: ({ getValue }: { getValue: () => string }) => (
        <Badge variant={getValue() === "active" ? "success" : "warning"}>{getValue()}</Badge>
      ),
    }),
    columnHelper.display({
      id: "actions",
      header: "Actions",
      cell: ({ row }: { row: { original: ItemRow; id: string } }) => (
        <Stack direction="horizontal" gap="sm">
          <Drawer direction="right">
            <DrawerTrigger asChild>
              <Button variant="outline" size="sm">Edit</Button>
            </DrawerTrigger>
            <DrawerContent>
              <DrawerHandle />
              <DrawerHeader>
                <DrawerTitle>Edit {row.original.name}</DrawerTitle>
                <DrawerDescription>Update the display name. Focus returns here when you close the drawer.</DrawerDescription>
              </DrawerHeader>
              <DrawerBody>
                <Field>
                  <FieldLabel htmlFor={`name-${row.id}`}>Name</FieldLabel>
                  <Input id={`name-${row.id}`} defaultValue={row.original.name} />
                </Field>
              </DrawerBody>
              <DrawerFooter>
                <Button type="button">Save</Button>
                <DrawerClose asChild>
                  <Button type="button" variant="outline">Close</Button>
                </DrawerClose>
              </DrawerFooter>
            </DrawerContent>
          </Drawer>
          <ConfirmDialog
            trigger={<Button variant="destructive" size="sm">Delete</Button>}
            title={`Delete ${row.original.name}?`}
            description="This removes the row from the table in this demo."
            onConfirm={() => {}}
          />
        </Stack>
      ),
    }),
  ];

  return (
    <AppShell sidebar={<Sidebar />} topbar={<TopBar />}>
      <PageHeader title="Projects" description="Services in this workspace. Edit a row or remove it." />
      <DataTable columns={columns} data={seed} pageSize={6} />
    </AppShell>
  );
}

Visible result: brand and nav in the sidebar, account in the top bar, a table of services, Edit opens a drawer with a name field, Delete opens a confirm dialog.

3. Mount the page

In app/page.tsx (or app/admin/page.tsx):

Route entry
import { AdminDashboardPage } from "./admin-dashboard-page";

export default function Page() {
  return <AdminDashboardPage />;
}

Run pnpm dev and open the route. Toggle light/dark using your theme control — tokens come from the theme step above.

Final result

One route renders AppShell, DataTable, Drawer + Field/Input, and ConfirmDialog without nested <button> inside <a>. Copy patterns from the catalog pages for Lay out an admin page, Show a sortable, filterable, paginated data table, Slide in a drawer panel, and Confirm destructive actions.

Troubleshooting

Unstyled controls — Kit CSS and Tailwind @source for @zedtech/zedkit-ui are missing. Re-run the theme guide; the table and buttons should use design tokens, not browser defaults.

Hydration warnings — Keep "use client" on the dashboard module (drawer and dialog use client state). The route file can stay a server component that imports the client view.

Focus lost after closing the drawer — Use DrawerClose (as in the snippet) so focus returns to the trigger; avoid unmounting the drawer with a bare conditional that skips the close animation.

Next steps

  • Add real data fetching in the route and pass rows as props into the view.
  • Replace the drawer field with your validation library; see Lay out fieldsets, fields, and error lists.
  • On mobile, the drawer goes full width — scroll the table horizontally only when columns overflow (content-shrink on the shell content).

On this page