Project Scaffold
Start with Next.js 15 App Router, Tailwind CSS, and shadcn/ui. shadcn gives you accessible, customizable components — not a black-box UI kit.
npx create-next-app@latest admin-dashboard --typescript --tailwind --app cd admin-dashboard npx shadcn@latest init # Core shadcn components we'll use npx shadcn@latest add button input label card table dialog dropdown-menu select badge avatar separator sheet breadcrumb skeleton toast sonner # Form + validation + table + charts npm install react-hook-form @hookform/resolvers zod npm install @tanstack/react-table npm install recharts npm install next-themes npm install lucide-react
Next.js for routing and server components · Tailwind for styling · shadcn/ui for components · React Hook Form + Zod for forms · TanStack Table for data tables
Dashboard Layout
Every admin page shares the same shell: sidebar on the left, header with breadcrumbs on top, scrollable content area. Use a route group so auth pages don't inherit the dashboard chrome.
// app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/layout/sidebar";
import { DashboardHeader } from "@/components/layout/dashboard-header";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<DashboardHeader />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}The layout is a Server Component. Sidebar and header can be client components where interactivity is needed (mobile toggle, theme switch).
Dark Mode
Use next-themes with shadcn's CSS variable system. One provider wraps the app; a toggle button switches themes without flash.
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<Toaster richColors position="top-right" />
</ThemeProvider>
);
}
// components/theme-toggle.tsx
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<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>
);
}Charts (Overview Page)
The dashboard home shows KPI cards and a revenue chart. Recharts integrates cleanly with shadcn's chart wrapper.
// app/(dashboard)/dashboard/page.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RevenueChart } from "@/components/charts/revenue-chart";
import { getDashboardStats } from "@/lib/queries/dashboard";
export default async function DashboardPage() {
const stats = await getDashboardStats();
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Overview</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{ label: "Total Revenue", value: stats.revenue, change: "+12.5%" },
{ label: "Users", value: stats.users, change: "+4.2%" },
{ label: "Orders", value: stats.orders, change: "+8.1%" },
{ label: "Products", value: stats.products, change: "+2" },
].map((kpi) => (
<Card key={kpi.label}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{kpi.label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{kpi.value}</div>
<p className="text-xs text-emerald-600">{kpi.change} from last month</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Revenue</CardTitle>
</CardHeader>
<CardContent>
<RevenueChart data={stats.chartData} />
</CardContent>
</Card>
</div>
);
}
// components/charts/revenue-chart.tsx
"use client";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
export function RevenueChart({ data }: { data: { month: string; revenue: number }[] }) {
return (
<ResponsiveContainer width="100%" height={350}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip />
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.15}
/>
</AreaChart>
</ResponsiveContainer>
);
}Data Tables with TanStack Table
TanStack Table handles sorting, column visibility, and row selection. Pair it with shadcn's table primitives for consistent styling.
// components/tables/data-table.tsx
"use client";
import {
ColumnDef, flexRender, getCoreRowModel,
getSortedRowModel, SortingState, useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import {
Table, TableBody, TableCell, TableHead,
TableHeader, TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: { sorting },
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}Search & Filters
Add a toolbar above the table with a search input and filter dropdowns. Debounce search to avoid hammering the API on every keystroke.
// components/tables/products-toolbar.tsx
"use client";
import { Input } from "@/components/ui/input";
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useDebounce } from "@/hooks/use-debounce";
import { useEffect, useState } from "react";
type Filters = {
search: string;
status: string;
category: string;
};
export function ProductsToolbar({
onFilterChange,
}: {
onFilterChange: (filters: Filters) => void;
}) {
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const [category, setCategory] = useState("all");
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
onFilterChange({ search: debouncedSearch, status, category });
}, [debouncedSearch, status, category, onFilterChange]);
return (
<div className="flex flex-wrap items-center gap-3 py-4">
<Input
placeholder="Search products…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="electronics">Electronics</SelectItem>
<SelectItem value="clothing">Clothing</SelectItem>
</SelectContent>
</Select>
</div>
);
}Pagination
Server-side pagination keeps large datasets fast. Pass page and pageSize as query params to your API.
// components/tables/data-table-pagination.tsx
"use client";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
type Props = {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
};
export function DataTablePagination({ page, pageSize, total, onPageChange }: Props) {
const totalPages = Math.ceil(total / pageSize);
const from = (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
return (
<div className="flex items-center justify-between px-2 py-4">
<p className="text-sm text-muted-foreground">
Showing {from}–{to} of {total}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
// API: GET /api/products?page=1&pageSize=10&search=phone&status=active
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get("page") ?? 1);
const pageSize = Number(searchParams.get("pageSize") ?? 10);
const search = searchParams.get("search") ?? "";
const status = searchParams.get("status");
const where = {
...(search && { name: { contains: search, mode: "insensitive" } }),
...(status && status !== "all" && { status }),
};
const [data, total] = await Promise.all([
db.product.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: "desc" },
}),
db.product.count({ where }),
]);
return Response.json({ data, total, page, pageSize });
}CRUD with React Hook Form + Zod
Create and edit products in a dialog. Zod validates on the client; the same schema validates on the server in your API route.
// lib/validations/product.ts
import { z } from "zod";
export const productSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z.string().optional(),
price: z.coerce.number().positive("Price must be positive"),
status: z.enum(["active", "draft", "archived"]),
category: z.string().min(1, "Category is required"),
imageUrl: z.string().url().optional().or(z.literal("")),
});
export type ProductFormValues = z.infer<typeof productSchema>;
// components/products/product-form-dialog.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { productSchema, type ProductFormValues } from "@/lib/validations/product";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
export function ProductFormDialog({
open, onOpenChange, defaultValues, onSuccess,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultValues?: Partial<ProductFormValues>;
onSuccess: () => void;
}) {
const isEdit = !!defaultValues?.name;
const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema),
defaultValues: {
name: "", description: "", price: 0,
status: "draft", category: "", imageUrl: "",
...defaultValues,
},
});
const onSubmit = async (values: ProductFormValues) => {
const url = isEdit ? `/api/products/${defaultValues?.id}` : "/api/products";
const method = isEdit ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
toast.error("Failed to save product");
return;
}
toast.success(isEdit ? "Product updated" : "Product created");
onOpenChange(false);
form.reset();
onSuccess();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Product" : "New Product"}</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...form.register("name")} />
{form.formState.errors.name && (
<p className="text-sm text-destructive mt-1">
{form.formState.errors.name.message}
</p>
)}
</div>
<div>
<Label htmlFor="price">Price</Label>
<Input id="price" type="number" step="0.01" {...form.register("price")} />
</div>
<div>
<Label>Status</Label>
<Select
value={form.watch("status")}
onValueChange={(v) => form.setValue("status", v as ProductFormValues["status"])}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}Image Uploads in Forms
Wire the upload dropzone from our file upload guide into the product form. On upload complete, set the imageUrl field value.
// components/products/image-upload-field.tsx
"use client";
import { useCallback } from "react";
import { useFormContext } from "react-hook-form";
import { UploadDropzone } from "@/components/upload/UploadDropzone";
import Image from "next/image";
export function ImageUploadField() {
const { setValue, watch } = useFormContext();
const imageUrl = watch("imageUrl");
const handleComplete = useCallback(
(files: { result?: { file: { public_url: string } } }[]) => {
const url = files[0]?.result?.file?.public_url;
if (url) setValue("imageUrl", url, { shouldValidate: true });
},
[setValue]
);
return (
<div className="space-y-3">
{imageUrl && (
<div className="relative h-32 w-32 overflow-hidden rounded-lg border">
<Image src={imageUrl} alt="Product" fill className="object-cover" />
</div>
)}
<UploadDropzone
bucket="products"
allowed={["image"]}
multiple={false}
maxFiles={1}
onComplete={handleComplete}
/>
</div>
);
}Toast Notifications
Sonner (included with shadcn) gives you rich toast notifications for CRUD success, errors, and undo actions — with zero extra setup beyond the provider.
import { toast } from "sonner";
// Success
toast.success("Product created successfully");
// Error with description
toast.error("Failed to delete", {
description: "This product has active orders.",
});
// Undo action
toast("Product deleted", {
action: {
label: "Undo",
onClick: () => restoreProduct(id),
},
});
// Promise toast (auto loading → success/error)
toast.promise(saveProduct(data), {
loading: "Saving…",
success: "Product saved",
error: "Failed to save",
});Loading Skeletons
Use shadcn Skeleton components for page-level loading states. In the App Router, colocate a loading.tsx file next to each route.
// app/(dashboard)/dashboard/products/loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
export default function ProductsLoading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-9 w-32" />
</div>
<div className="flex gap-3">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-36" />
<Skeleton className="h-9 w-40" />
</div>
<div className="rounded-md border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b p-4">
<Skeleton className="h-10 w-10 rounded" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20 ml-auto" />
</div>
))}
</div>
</div>
);
}Optimistic Updates
When deleting or toggling status, update the UI immediately and roll back if the API call fails. This makes the dashboard feel instant.
// hooks/use-products.ts
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
type Product = {
id: string;
name: string;
price: number;
status: string;
};
export function useProducts(filters: { search: string; status: string; page: number }) {
const [products, setProducts] = useState<Product[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const fetchProducts = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({
page: String(filters.page),
search: filters.search,
status: filters.status,
});
const res = await fetch(`/api/products?${params}`);
const json = await res.json();
setProducts(json.data);
setTotal(json.total);
setLoading(false);
}, [filters]);
useEffect(() => { fetchProducts(); }, [fetchProducts]);
const deleteProduct = async (id: string) => {
const previous = products;
setProducts((prev) => prev.filter((p) => p.id !== id));
setTotal((t) => t - 1);
const res = await fetch(`/api/products/${id}`, { method: "DELETE" });
if (!res.ok) {
setProducts(previous);
setTotal((t) => t + 1);
toast.error("Failed to delete product");
return;
}
toast.success("Product deleted");
};
const toggleStatus = async (id: string, newStatus: string) => {
const previous = products;
setProducts((prev) =>
prev.map((p) => (p.id === id ? { ...p, status: newStatus } : p))
);
const res = await fetch(`/api/products/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) {
setProducts(previous);
toast.error("Failed to update status");
}
};
return { products, total, loading, deleteProduct, toggleStatus, refetch: fetchProducts };
}Folder Structure
Everything wired together — copy this structure into your project.
admin-dashboard/ ├── app/ │ ├── (auth)/ │ │ ├── login/page.tsx │ │ └── layout.tsx │ ├── (dashboard)/ │ │ ├── layout.tsx # Sidebar + header shell │ │ └── dashboard/ │ │ ├── page.tsx # Overview + charts │ │ ├── loading.tsx │ │ ├── users/ │ │ │ ├── page.tsx │ │ │ └── loading.tsx │ │ ├── products/ │ │ │ ├── page.tsx # Table + CRUD │ │ │ └── loading.tsx │ │ ├── analytics/page.tsx │ │ └── settings/page.tsx │ ├── api/ │ │ ├── products/ │ │ │ ├── route.ts # GET (list) + POST (create) │ │ │ └── [id]/route.ts # GET + PATCH + DELETE │ │ ├── users/route.ts │ │ ├── upload/route.ts │ │ └── dashboard/stats/route.ts │ ├── layout.tsx # Root layout + Providers │ └── globals.css ├── components/ │ ├── layout/ │ │ ├── sidebar.tsx │ │ └── dashboard-header.tsx │ ├── charts/ │ │ └── revenue-chart.tsx │ ├── tables/ │ │ ├── data-table.tsx │ │ ├── data-table-pagination.tsx │ │ ├── products-toolbar.tsx │ │ └── product-columns.tsx │ ├── products/ │ │ ├── product-form-dialog.tsx │ │ └── image-upload-field.tsx │ ├── upload/ │ │ ├── UploadDropzone.tsx │ │ └── useUpload.ts │ ├── theme-toggle.tsx │ └── ui/ # shadcn components ├── hooks/ │ ├── use-products.ts │ ├── use-debounce.ts │ └── use-media-query.ts ├── lib/ │ ├── db.ts │ ├── utils.ts │ ├── validations/ │ │ └── product.ts │ ├── queries/ │ │ └── dashboard.ts │ └── storage/ ├── prisma/ │ └── schema.prisma └── middleware.ts
Components Reference
| Component | Purpose |
|---|---|
Sidebar | Collapsible nav with active route highlighting |
DashboardHeader | Breadcrumbs + theme toggle |
DataTable | Sortable table powered by TanStack Table |
ProductsToolbar | Search input + status/category filters |
DataTablePagination | Server-side page navigation |
ProductFormDialog | Create/edit modal with RHF + Zod |
ImageUploadField | Drag & drop image in forms |
RevenueChart | Area chart for dashboard overview |
ThemeToggle | Light / dark / system switch |
Hooks Reference
| Hook | Purpose |
|---|---|
useProducts | Fetch, delete, toggle status with optimistic updates |
useDebounce | Debounce search input (300ms default) |
useUpload | File upload state, progress, and previews |
useMediaQuery | Responsive sidebar collapse on mobile |
API Routes
| Route | Methods | Description |
|---|---|---|
/api/products | GET, POST | List with pagination/search/filters, create |
/api/products/[id] | GET, PATCH, DELETE | Read, update, delete single product |
/api/users | GET | List users with pagination |
/api/upload | POST, DELETE | Upload and remove files |
/api/dashboard/stats | GET | KPI counts + chart data |
Database
Prisma with PostgreSQL is a solid default. Here's the schema for products and users — extend as needed.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(MEMBER)
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
status ProductStatus @default(DRAFT)
category String
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([category])
@@index([createdAt])
}
enum Role {
ADMIN
MEMBER
VIEWER
}
enum ProductStatus {
ACTIVE
DRAFT
ARCHIVED
}npx prisma migrate dev --name init npx prisma db seed # optional: seed dashboard stats
Deployment
Deploy to Vercel for the simplest Next.js experience, or Render if you need managed PostgreSQL in the same platform.
Vercel (recommended)
- Connect GitHub repo → auto-deploy on push
- Set env vars:
DATABASE_URL, storage keys - Run
prisma migrate deployin build step - Edge middleware works out of the box
Render
- Web Service for Next.js + Managed PostgreSQL
- Build:
npm install && npx prisma generate && npm run build - Start:
npm start - Internal DB URL for server-side queries
Pre-deploy checklist
- Environment variables set for all environments (preview + production)
- Database migrations applied
- Auth middleware protecting
/(dashboard)routes - File upload storage bucket created and policies configured
- Seed data or admin user created for first login
- Error monitoring connected (Sentry or similar)
Need a custom admin dashboard built?
We design and ship admin panels for SaaS products — multi-tenant, role-based, with analytics, billing, and integrations built in.