’x—️

How to Build an Admin Dashboard in Next.js

A complete step-by-step guide — sidebar layout, metric cards, data tables, authentication, and real database connections

G-Tech Blog  |  2026

Admin 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 smooth 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.

’a— Why Next.js for Admin Dashboards?

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.

Server Components

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.

File-Based Routing

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.

Built-in API Routes

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.

Vercel Deployment

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.

’x—️ What We Will Build

By the end of this guide, you will have a fully functional admin dashboard with the following features:

’x`

Overview Page

Metric cards showing key stats and a revenue chart

’x—

Users Table

Searchable, filterable user list with CRUD actions

’x—

Orders Page

Order management with status badges and filtering

’x—

Authentication

Login with NextAuth.js and protected routes via Middleware

’x:—️

Role-Based Access

Admin vs User roles with conditional UI rendering

’xR"

Dark Mode

System-aware dark/light theme with manual toggle

“& Prerequisites

Before starting, make sure you have the following set up:

TypeScript throughout. This guide uses TypeScript for all code examples. TypeScript's type safety is especially valuable in admin dashboards where you are working with complex data shapes — user records, order objects, permission levels — and a type error caught at compile time is far better than a runtime crash in a production admin panel.

’x9 Step 1: Plan Your Dashboard

Rushing 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.

1. What data does your dashboard manage?

For this tutorial: users, orders, and revenue analytics. In your own project it might be products, blog posts, bookings, support tickets, or inventory.

2. What pages do you need?

  • /dashboard — Overview with summary metrics and charts
  • /dashboard/users — User list with search, filter, and management actions
  • /dashboard/orders — Order list with status tracking
  • /dashboard/analytics — Detailed charts and reports
  • /dashboard/settings — Profile and application settings
  • /login — Authentication page (outside the dashboard layout)

3. What actions do users need to perform?

Create new records, edit existing ones, delete records, change user roles, export data as CSV, and filter/search large data sets.

4. Who has access to what?

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.

’xa— Step 2: Create the Project

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.

’x}— Step 3: Install Tailwind CSS

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;

’x— Step 4: Set Up the Folder Structure

A well-organized folder structure makes a dashboard codebase maintainable as it grows. Here is the recommended structure for this project:

admin-dashboard/
  app/
    api/ — — API route handlers
      users/
        route.ts
      orders/
        route.ts
    dashboard/ — — protected dashboard routes
      layout.tsx — — shared sidebar + topbar layout
      page.tsx — — overview / home page
      users/
        page.tsx
      orders/
        page.tsx
      analytics/
        page.tsx
      settings/
        page.tsx
    login/
      page.tsx
    layout.tsx — — root layout
    globals.css
  components/ — — reusable UI components
    Sidebar.tsx
    Topbar.tsx
    MetricCard.tsx
    DataTable.tsx
    StatusBadge.tsx
    Modal.tsx
  lib/ — — utilities and config
    prisma.ts
    auth.ts
    utils.ts
  types/ — — TypeScript type definitions
    index.ts
  middleware.ts — — route protection
  prisma/schema.prisma

’x—— Step 5: Create the Dashboard Layout

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: '’x`' },
  { href: '/dashboard/users',      label: 'Users',      icon: '’x—' },
  { href: '/dashboard/orders',     label: 'Orders',     icon: '’x—' },
  { href: '/dashboard/analytics',  label: 'Analytics',  icon: '’x—' },
  { href: '/dashboard/settings',   label: 'Settings',   icon: '’a"️' },
];

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">’a— 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>
  );
}

’x— Step 7: Add a Top Navigation Bar

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">
          ’x
          <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>
  );
}

’x` Step 8: Build the Overview Page with Metric Cards

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:

MetricCard Component

// 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>
  );
}

Overview Page

// app/dashboard/page.tsx
import MetricCard from '@/components/MetricCard';

const metrics = [
  { title: 'Total Users',      value: '12,487', change: '8.2%',  positive: true,  icon: '’x—' },
  { title: 'Monthly Revenue',  value: '$48,320', change: '12.5%', positive: true,  icon: '’x—' },
  { title: 'New Orders',       value: '1,024',  change: '3.1%',  positive: true,  icon: '’x—' },
  { title: 'Active Sessions',  value: '342',    change: '1.8%',  positive: false, icon: '’a—' },
];

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>
  );
}

’x— Step 9: Add Charts with Recharts

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 smoothly 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.

’x— Step 10: Build the Users Table

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
}

“—️ Step 12: Implement CRUD Actions

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>
Always validate and authorize in Server Actions. Never trust client-side input without server-side validation. Use a library like Zod to validate form data shape and type, and check the user's session to confirm they have permission to perform the action before executing any database operation.

’x— Step 13: Add Authentication with NextAuth.js

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;
    },
  },
});

Create the Route Handler

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

Login Page

// 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>
  );
}

’x:—️ Step 14: Protect Routes with Middleware

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.

’x}— Step 15: Role-Based Access Control

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.

Check role in a Server Component

// 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
}

Conditionally render UI based on role

// 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>
);

’x️ Step 16: Connect a Real Database with Prisma

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

Define your schema in prisma/schema.prisma

generator 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])
}

Create a singleton Prisma client

// 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;

Fetch real users in the page

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

’xR Step 17: Build API Routes

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 });
}

’xR" Step 18: Add Dark Mode

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' ? '—ܬ️' : '’xR"'}
    </button>
  );
}

Add <DarkModeToggle /> to your Topbar component and every page in your dashboard will instantly support light/dark switching.

’xa— Step 19: Deploy to Vercel

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.

  1. Push your project to a GitHub repository
  2. Go to vercel.com and sign in with GitHub
  3. Click New Project and import your repository
  4. In the "Environment Variables" section, add all your .env variables:
    • DATABASE_URL — your Neon or other Postgres connection string
    • NEXTAUTH_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)
  5. Click Deploy

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.

Use Vercel's preview deployments for testing. Every pull request you open on GitHub gets its own unique preview URL automatically. This lets you test database migrations, new features, and UI changes in a production-like environment before merging to main — without any risk to the live dashboard. Share the preview URL with stakeholders or team members for review.

—~" What to Add Next

With the core dashboard built and deployed, here are the most valuable additions to consider for a production-grade admin panel:

Pagination

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.

CSV Export

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.

Audit Logs

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.

Real-Time Updates

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.

Email Notifications

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.

’x}0 Conclusion

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's one of the most impressive and versatile projects a web developer can show. Now go build yours. ’xa—