A complete step-by-step guide — sidebar layout, metric cards, data tables, authentication, and real database connections
G-Tech Blog | 2026Admin dashboards are the backbone of modern web applications. Whether you are building a SaaS product, an e-commerce store, a content management system, or an internal business tool, you will need a central control panel where authorized users can manage data, monitor metrics, handle users, and perform administrative actions. Next.js — with its App Router, Server Components, built-in API routes, and seamless deployment to Vercel — is one of the best frameworks available for building production-quality admin dashboards in 2026. This complete guide walks through every step from project creation to authentication, real database integration, and deployment.
There are many ways to build a web application dashboard — plain React, Vue, Angular, or even a server-rendered framework like Laravel or Rails. So why choose Next.js specifically? In 2026, Next.js stands out for several reasons that are particularly well-suited to admin panel requirements.
Next.js Server Components allow you to fetch data directly in your page components without exposing API keys or database credentials to the browser. For an admin panel — where security and data sensitivity are paramount — this is a significant architectural advantage. Your database queries run on the server, and only the rendered HTML is sent to the client.
Every file in the /app directory automatically becomes a route. Creating a new dashboard page
is as simple as adding a new folder and a page.tsx file — no router configuration needed. This
makes the codebase highly organized and navigable as the dashboard grows in complexity.
Next.js allows you to write backend API endpoints in the same project as your frontend, using
route.ts files inside the /app/api folder. For an admin dashboard, this means your
CRUD operations, data exports, and webhook handlers all live in one codebase without needing a separate
Express or Fastify server.
Deploying a Next.js dashboard to Vercel takes less than five minutes. Every push to your GitHub repository triggers an automatic rebuild and deploy. Preview deployments for pull requests let you test changes before they reach production. The free Hobby tier is sufficient for many internal tools and early-stage SaaS products.
By the end of this guide, you will have a fully functional admin dashboard with the following features:
Metric cards showing key stats and a revenue chart
Searchable, filterable user list with CRUD actions
Order management with status badges and filtering
Login with NextAuth.js and protected routes via Middleware
Admin vs User roles with conditional UI rendering
System-aware dark/light theme with manual toggle
Before starting, make sure you have the following set up:
node --versionRushing into code without a clear plan is one of the most common reasons dashboard projects become disorganized and difficult to maintain. Spending 30 minutes planning the structure before writing a single line of code saves hours of refactoring later. Answer these four questions before you start.
For this tutorial: users, orders, and revenue analytics. In your own project it might be products, blog posts, bookings, support tickets, or inventory.
Create new records, edit existing ones, delete records, change user roles, export data as CSV, and filter/search large data sets.
Define roles upfront: Admin can do everything. Manager can view and edit but not delete. Viewer can only read data. Role definitions made early prevent security holes later.
Use the official Next.js project generator to scaffold a new application. When prompted, choose TypeScript, ESLint, Tailwind CSS (if you prefer to install it this way), and the App Router:
npx create-next-app@latest admin-dashboard --typescript --eslint --app
cd admin-dashboard
Once the project is created, start the development server to confirm everything works:
npm run dev
Open http://localhost:3000 in your browser. You should see the default Next.js welcome page. Now
you are ready to build.
Tailwind CSS is the most efficient styling approach for admin dashboards. Its utility-first classes let you build complex layouts and responsive designs directly in your JSX without creating separate CSS files for every component. Install and configure it:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Update tailwind.config.ts to scan your app files:
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class', // enables class-based dark mode
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}
export default config
Replace the contents of app/globals.css with the Tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;
A well-organized folder structure makes a dashboard codebase maintainable as it grows. Here is the recommended structure for this project:
The dashboard layout is a shared wrapper applied to all routes inside /app/dashboard/. It renders
the sidebar on the left and a main content area on the right. Because it is a Next.js layout, it only renders
once and persists across page navigations — the sidebar does not flicker or re-render every time the user clicks
a navigation link.
// app/dashboard/layout.tsx
import type { ReactNode } from 'react';
import Sidebar from '@/components/Sidebar';
import Topbar from '@/components/Topbar';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Sidebar — fixed left column */}
<Sidebar />
{/* Main content area */}
<div className="flex flex-col flex-1 ml-64">
<Topbar />
<main className="flex-1 p-8">
{children}
</main>
</div>
</div>
);
}
The sidebar is the primary navigation element of any admin dashboard. It needs to be clearly organized, visually
indicate the current active page, and be collapsible on mobile screens. Here is a clean sidebar component using
Tailwind CSS and the usePathname hook from Next.js to highlight the active link:
// components/Sidebar.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: '📊' },
{ href: '/dashboard/users', label: 'Users', icon: '👥' },
{ href: '/dashboard/orders', label: 'Orders', icon: '📦' },
{ href: '/dashboard/analytics', label: 'Analytics', icon: '📈' },
{ href: '/dashboard/settings', label: 'Settings', icon: '⚙️' },
];
export default function Sidebar() {
const pathname = usePathname();
return (
<aside className="w-64 fixed top-0 left-0 h-full bg-gray-900 text-white flex flex-col z-40">
{/* Brand */}
<div className="px-6 py-5 border-b border-gray-700">
<span className="text-xl font-bold">⚡ AdminPanel</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors
${isActive
? 'bg-indigo-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
{/* User section at bottom */}
<div className="px-6 py-4 border-t border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center text-sm font-bold">A</div>
<div>
<p className="text-sm font-medium">Admin User</p>
<p className="text-xs text-gray-400">admin@example.com</p>
</div>
</div>
</div>
</aside>
);
}
The top bar sits above the main content area and provides global actions accessible from any page — notifications, a user profile menu, a search input, and a dark mode toggle. Keep it simple and unobtrusive:
// components/Topbar.tsx
'use client';
export default function Topbar() {
return (
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700
flex items-center justify-between px-8 sticky top-0 z-30">
{/* Search */}
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Search..."
className="w-72 px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700
border-0 outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-4">
{/* Notification bell */}
<button className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
🔔
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
{/* Avatar */}
<div className="w-9 h-9 rounded-full bg-indigo-600 flex items-center
justify-content-center text-white text-sm font-bold cursor-pointer">
A
</div>
</div>
</header>
);
}
The overview page is the first thing admins see when they log in. It should display the most important numbers
at a glance — total users, revenue, orders, and active sessions — and give a visual summary of recent trends.
Start with a reusable MetricCard component, then compose the overview page from multiple cards:
// components/MetricCard.tsx
interface MetricCardProps {
title: string;
value: string;
change: string;
positive: boolean;
icon: string;
}
export default function MetricCard({ title, value, change, positive, icon }: MetricCardProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
<span className="text-2xl">{icon}</span>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white mb-2">{value}</p>
<p className={`text-sm font-medium ${positive ? 'text-green-600' : 'text-red-500'}`}>
{positive ? '↑' : '↓'} {change} from last month
</p>
</div>
);
}
// app/dashboard/page.tsx
import MetricCard from '@/components/MetricCard';
const metrics = [
{ title: 'Total Users', value: '12,487', change: '8.2%', positive: true, icon: '👥' },
{ title: 'Monthly Revenue', value: '$48,320', change: '12.5%', positive: true, icon: '💰' },
{ title: 'New Orders', value: '1,024', change: '3.1%', positive: true, icon: '📦' },
{ title: 'Active Sessions', value: '342', change: '1.8%', positive: false, icon: '⚡' },
];
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Overview</h1>
{/* Metric cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
{metrics.map((m) => (
<MetricCard key={m.title} {...m} />
))}
</div>
</div>
);
}
Charts transform raw numbers into visual trends that are far easier to understand at a glance. Recharts is the most popular charting library for React — it is simple, composable, and works seamlessly in Next.js client components.
npm install recharts
// components/RevenueChart.tsx
'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const data = [
{ month: 'Jan', revenue: 32000 },
{ month: 'Feb', revenue: 38000 },
{ month: 'Mar', revenue: 35000 },
{ month: 'Apr', revenue: 42000 },
{ month: 'May', revenue: 48000 },
{ month: 'Jun', revenue: 45000 },
{ month: 'Jul', revenue: 52000 },
];
export default function RevenueChart() {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Monthly Revenue</h3>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} tickFormatter={(v) => `$${v/1000}k`} />
<Tooltip formatter={(v) => [`$${v.toLocaleString()}`, 'Revenue']} />
<Line type="monotone" dataKey="revenue" stroke="#4F46E5" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
Import and add <RevenueChart /> to your dashboard overview page below the metric cards.
Data tables are the core interface element of most admin dashboards. A well-built table displays records clearly, supports sorting and filtering, and provides row-level action buttons. Here is a clean users table:
// app/dashboard/users/page.tsx
import StatusBadge from '@/components/StatusBadge';
const users = [
{ id: 1, name: 'Alice Mwangi', email: 'alice@example.com', role: 'Admin', status: 'Active', joined: '2025-01-12' },
{ id: 2, name: 'Bob Ochieng', email: 'bob@example.com', role: 'Editor', status: 'Active', joined: '2025-03-22' },
{ id: 3, name: 'Carol Njeri', email: 'carol@example.com', role: 'Viewer', status: 'Pending', joined: '2025-06-05' },
{ id: 4, name: 'David Kamau', email: 'david@example.com', role: 'Editor', status: 'Suspended',joined: '2024-11-18' },
];
export default function UsersPage() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Users</h1>
<button className="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors">
+ Add User
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Joined</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-6 py-4">
<div>
<p className="font-medium text-gray-900 dark:text-white">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">{user.role}</td>
<td className="px-6 py-4"><StatusBadge status={user.status} /></td>
<td className="px-6 py-4 text-sm text-gray-500">{user.joined}</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button className="text-indigo-600 hover:text-indigo-800 text-sm font-medium">Edit</button>
<button className="text-red-500 hover:text-red-700 text-sm font-medium">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Large data tables need search and filter functionality. Next.js provides a clean pattern for this using URL search parameters — the search state lives in the URL, making results shareable and bookmarkable. Add a search input that updates the URL params, then read them in your Server Component to filter results:
// components/SearchInput.tsx
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function SearchInput({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
<input
type="text"
placeholder={placeholder}
defaultValue={searchParams.get('query') ?? ''}
onChange={(e) => handleSearch(e.target.value)}
className="w-72 px-4 py-2 text-sm rounded-lg bg-gray-100 dark:bg-gray-700
border-0 outline-none focus:ring-2 focus:ring-indigo-500"
/>
);
}
npm install use-debounce
In your users page, read the query search param and filter the users array (or pass it to your
database query):
// app/dashboard/users/page.tsx
export default function UsersPage({ searchParams }: { searchParams: { query?: string } }) {
const query = searchParams?.query?.toLowerCase() ?? '';
const filteredUsers = users.filter(
(u) => u.name.toLowerCase().includes(query) || u.email.toLowerCase().includes(query)
);
// ... render filteredUsers in the table
}
Admin dashboards need to create, read, update, and delete records. Next.js Server Actions provide a clean way to
handle mutations without writing separate API endpoints. A Server Action is a function marked with
'use server' that runs securely on the server when called from a form or button click:
// app/dashboard/users/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
export async function deleteUser(id: number) {
await prisma.user.delete({ where: { id } });
revalidatePath('/dashboard/users');
}
export async function updateUserRole(id: number, role: string) {
await prisma.user.update({
where: { id },
data: { role },
});
revalidatePath('/dashboard/users');
}
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
await prisma.user.create({ data: { name, email, role: 'Viewer', status: 'Pending' } });
revalidatePath('/dashboard/users');
}
Call deleteUser from a delete button using a form action:
<form action={deleteUser.bind(null, user.id)}>
<button type="submit" className="text-red-500 hover:text-red-700 text-sm font-medium">
Delete
</button>
</form>
Authentication is the most critical security layer of an admin dashboard. NextAuth.js (Auth.js v5) is the most widely used authentication solution for Next.js. It supports Google, GitHub, and email/password credentials out of the box, integrates directly with the App Router, and handles session management, CSRF protection, and token rotation automatically.
npm install next-auth@beta
// lib/auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Query your database and verify the password hash
const user = await getUserByEmail(credentials.email as string);
if (!user) return null;
const valid = await bcrypt.compare(credentials.password as string, user.passwordHash);
return valid ? user : null;
},
}),
],
pages: {
signIn: '/login', // custom login page
},
callbacks: {
async session({ session, token }) {
// Attach user role to session for RBAC
if (token.role) session.user.role = token.role as string;
return session;
},
async jwt({ token, user }) {
if (user) token.role = (user as any).role;
return token;
},
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
// app/login/page.tsx
import { signIn } from '@/lib/auth';
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-2xl p-8 shadow-lg w-full max-w-md">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Admin Login</h1>
<form action={async (formData) => {
'use server';
await signIn('credentials', formData);
}}>
<input name="email" type="email" placeholder="Email" required
className="w-full px-4 py-3 mb-4 rounded-lg border border-gray-200 text-sm outline-none focus:ring-2 focus:ring-indigo-500" />
<input name="password" type="password" placeholder="Password" required
className="w-full px-4 py-3 mb-6 rounded-lg border border-gray-200 text-sm outline-none focus:ring-2 focus:ring-indigo-500" />
<button type="submit"
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-medium hover:bg-indigo-700 transition-colors">
Sign In
</button>
</form>
</div>
</div>
);
}
Next.js Middleware runs before a request is processed and can redirect unauthenticated users away from protected
pages before they even load. Create a middleware.ts file at the project root to protect all
dashboard routes:
// middleware.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isDashboard = req.nextUrl.pathname.startsWith('/dashboard');
if (isDashboard && !isLoggedIn) {
// Redirect unauthenticated users to the login page
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
if (req.nextUrl.pathname === '/login' && isLoggedIn) {
// Redirect already-logged-in users away from the login page
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
}
});
// Apply middleware only to these paths
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
With this middleware in place, any user who tries to access /dashboard or any sub-route without
an active session will be automatically redirected to /login. This protection happens at the edge
— before the page component even renders — making it the most secure and performant way to protect routes in
Next.js.
Role-Based Access Control (RBAC) restricts what different types of users can see and do inside the dashboard. An Admin can manage all users and delete records. A Manager can edit but not delete. A Viewer can only read. Implement RBAC by checking the user's role from the session in both UI components and server actions.
// app/dashboard/users/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function UsersPage() {
const session = await auth();
// Only Admins can access the full users management page
if (session?.user?.role !== 'Admin') {
redirect('/dashboard');
}
// ... rest of the page
}
// In any component — show delete button only to Admins
const session = await auth();
const isAdmin = session?.user?.role === 'Admin';
return (
<div>
{isAdmin && (
<button className="text-red-500 text-sm">Delete</button>
)}
</div>
);
Static arrays are only useful for learning. A real admin dashboard connects to a database. Prisma is the most popular ORM for Next.js — it provides type-safe database access, automatic migrations, and an excellent developer experience. Pair it with Neon (free serverless Postgres) for a production-ready database setup at no cost.
npm install prisma @prisma/client
npx prisma init
prisma/schema.prismagenerator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
role String @default("Viewer")
status String @default("Active")
createdAt DateTime @default(now())
orders Order[]
}
model Order {
id Int @id @default(autoincrement())
total Float
status String @default("Pending")
createdAt DateTime @default(now())
userId Int
user User @relation(fields: [userId], references: [id])
}
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ?? new PrismaClient({ log: ['query'] });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// app/dashboard/users/page.tsx
import { prisma } from '@/lib/prisma';
export default async function UsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
// render users in table...
}
# Run migration to create database tables
npx prisma migrate dev --name init
# Open Prisma Studio to view your data visually
npx prisma studio
While Server Actions handle most mutations, you will sometimes need traditional REST API endpoints — for
example, when a mobile app or third-party service needs to consume your data. Next.js API routes are defined as
route.ts files inside /app/api/:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
// GET /api/users — fetch all users (admin only)
export async function GET() {
const session = await auth();
if (!session || session.user?.role !== 'Admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const users = await prisma.user.findMany({ orderBy: { createdAt: 'desc' } });
return NextResponse.json(users);
}
// POST /api/users — create a new user (admin only)
export async function POST(request: Request) {
const session = await auth();
if (!session || session.user?.role !== 'Admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const user = await prisma.user.create({
data: { name: body.name, email: body.email, role: body.role ?? 'Viewer' },
});
return NextResponse.json(user, { status: 201 });
}
Dark mode is now a standard expectation for any professional admin dashboard. With Tailwind's
darkMode: 'class' configuration already set up, implementing dark mode requires adding a toggle
that switches a dark class on the root <html> element. Use the
next-themes library, which handles system preference detection, localStorage persistence, and
hydration flashing automatically:
npm install next-themes
// app/layout.tsx — wrap the app with ThemeProvider
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
// components/DarkModeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
export default function DarkModeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle dark mode"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
Add <DarkModeToggle /> to your Topbar component and every page in your dashboard will
instantly support light/dark switching.
Deploying your Next.js admin dashboard to Vercel is fast, free, and requires no server configuration. Vercel is the platform built by the Next.js team and is optimized for Next.js deployments — Server Components, API routes, and Server Actions all work out of the box with zero configuration.
.env variables:
DATABASE_URL — your Neon or other Postgres connection stringNEXTAUTH_SECRET — a random 32-character secret (generate with
openssl rand -base64 32)
NEXTAUTH_URL — your production domain (e.g. https://your-app.vercel.app)
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (if using Google OAuth)Vercel automatically detects Next.js, builds the project, and deploys it to a global CDN. Your dashboard will
be live at https://your-project.vercel.app in under two minutes. Every subsequent push to the
main branch triggers an automatic redeploy.
With the core dashboard built and deployed, here are the most valuable additions to consider for a production-grade admin panel:
Large datasets need pagination or infinite scroll. Implement URL-param based pagination with Prisma's
skip and take options and navigation buttons in the table footer. This prevents
loading thousands of records at once and keeps the dashboard fast.
Admins frequently need to export data for reporting. Create an API route that queries your database and
returns a CSV file using the Content-Disposition: attachment header. A simple "Export CSV"
button in the table header triggers the download.
Track every create, update, and delete action with a dedicated AuditLog table in your
database. Store who performed the action, what was changed, and when. An audit log page in the dashboard
lets admins investigate changes and maintain accountability.
Use Supabase Realtime or Pusher to push live updates to the dashboard without page refreshes. New orders appearing in the orders table as they come in, or notification counts updating in real time, significantly improve the admin experience for high-traffic applications.
Integrate Resend or Nodemailer to send automated email alerts for important events — a new user registration, an order status change, a failed payment, or a security alert. Triggered from Server Actions, email notifications keep admins informed even when they are not actively watching the dashboard.
Building a professional admin dashboard in Next.js is a highly rewarding project that touches nearly every skill in modern web development: component architecture, routing, authentication, database design, API development, role-based authorization, and deployment. The step-by-step approach in this guide — starting with a layout, adding pages one by one, then layering in authentication and real data — mirrors how professional teams actually build internal tools in production.
The stack covered in this guide — Next.js App Router, Tailwind CSS, NextAuth.js, Prisma, Neon Postgres, and Vercel — is a proven combination that balances developer experience, performance, and cost. All of the core components are free for individual projects and small teams, making this an excellent foundation for SaaS products, internal tools, client projects, and portfolio showcases alike.
The most important thing after reading a tutorial is to build something with it. Take this foundation and add your own data models, your own design, and your own features. Every problem you encounter while building a real dashboard — a tricky query, a layout bug, a confusing auth flow — will teach you something that reading alone never can. Commit to GitHub often, iterate on the design, and deploy early so you can share it with others.
An admin dashboard in your portfolio demonstrates full-stack capability in a single project — frontend, backend, authentication, database, and deployment. It is one of the most impressive and versatile projects a web developer can show. Now go build yours. 🚀