Creating a Dashboard in Next.js - Complete Step-by-Step Guide
Learn how to implement a fully functional dashboard in your Next.js application with charts, data tables, and responsive design.
Adding a Dashboard to a Next.js Project
This guide will walk you through creating a modern, responsive dashboard in your Next.js application.
Prerequisites
-
Next.js 15+ project with App Router
-
Basic knowledge of React and Next.js
-
Node.js installed on your system
-
You need to create a dashboard page group, if you havent
app/(dashboard)/dashboard/ ├── layout.tsx ├── page.tsx ├── components/ │ ├── Sidebar.tsx │ ├── Header.tsx │ ├── StatsGrid.tsx │ ├── Chart.tsx │ └── DataTable.tsx └── styles/ └── dashboard.module.css
Step 1: Create Dashboard Layout
import Navbar from "@/components/dashboard/Navbar";
import Sidebar from "@/components/dashboard/Sidebar";
import { GridBackground } from "@/components/reusable-ui/grid-background";
import { authOptions } from "@/config/auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React, { ReactNode } from "react";
export default async function DashboardLayout({
children,
}: {
children: ReactNode;
}) {
const session = await getServerSession(authOptions);
const userRole = session?.user.role;
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
{/* Fixed Sidebar */}
<aside className="fixed left-0 top-0 z-40 hidden h-screen md:block">
<Sidebar />
</aside>
{/* Main Content */}
<main className="flex-1 md:ml-[220px] lg:ml-[280px]">
<Navbar session={session} />
<div className="bg-gray-50">{children}</div>
</main>
</div>
);
}
Step 2 : Create the Navbar.tsx Component
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import {
Bell,
Search,
LogOut,
User,
Settings,
HelpCircle,
ChevronDown,
Check,
CheckCheck,
FileCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Session } from "next-auth";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { NotificationType } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import {
getNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
} from "@/actions/notifactions";
export default function Navbar({ session }: { session: Session }) {
const router = useRouter();
const role = session.user.roles[0]?.roleName;
const [profileOpen, setProfileOpen] = useState(false);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (notificationsOpen) {
fetchNotifications();
}
}, [notificationsOpen]);
const fetchNotifications = async () => {
setLoading(true);
try {
const response = await getNotifications({ limit: 5 });
if (response.success && response.data) {
setNotifications(response.data);
}
} catch (error) {
console.error("Failed to fetch notifications:", error);
} finally {
setLoading(false);
}
};
async function handleLogout() {
try {
await signOut();
router.push("/login");
} catch (error) {
console.log(error);
}
}
const handleMenuItemClick = (path: string) => {
setProfileOpen(false);
router.push(path);
};
const handleMarkAsRead = async (id: string) => {
try {
const response = await markNotificationAsRead(id);
if (response.success) {
fetchNotifications();
}
} catch (error) {
console.error("Failed to mark notification as read:", error);
}
};
const handleMarkAllAsRead = async () => {
try {
const response = await markAllNotificationsAsRead();
if (response.success) {
fetchNotifications();
}
} catch (error) {
console.error("Failed to mark all notifications as read:", error);
}
};
const handleViewAllNotifications = () => {
setNotificationsOpen(false);
router.push("/dashboard/notifications");
};
// Get unread notifications count
const unreadCount = notifications.filter((n) => !n.isRead).length;
// Get notification icon based on type
const getNotificationIcon = (type: NotificationType) => {
switch (type) {
case "DOCUMENT_ALERT":
return <Bell className="h-4 w-4 text-blue-500" />;
case "STATUS_CHANGE":
return <FileCheck className="h-4 w-4 text-green-500" />;
case "TASK_ASSIGNED":
return <User className="h-4 w-4 text-orange-500" />;
case "DEADLINE_APPROACHING":
return <Bell className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4" />;
}
};
return (
<header className="bg-background/95 sticky top-0 z-30 flex h-16 w-full items-center justify-between border-b px-2 backdrop-blur-sm lg:px-6">
{/* Center - Search (only on desktop) */}
<div className="mx-4 hidden w-full max-w-md md:block">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search className="text-muted-foreground h-4 w-4" />
</div>
<Input
type="text"
placeholder="Search shipments, documents, clients..."
className="bg-muted/30 w-full pl-10 text-xs"
/>
</div>
</div>
{/* Right side - Notifications and User Menu */}
<div className="flex items-center space-x-4">
{/* Notifications */}
<div className="relative">
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setNotificationsOpen(!notificationsOpen)}
>
<Bell className="text-2xl" />
{unreadCount > 0 && (
<span className="bg-destructive absolute right-0 top-0 flex h-5 w-5 -translate-y-1/3 translate-x-1/3 transform items-center justify-center rounded-full text-xs text-white">
{unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
{notificationsOpen && (
<div className="bg-background absolute right-0 z-50 mt-2 w-80 rounded-md shadow-lg ring-1 ring-black ring-opacity-5">
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-medium">Notifications</h3>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="flex items-center text-xs"
onClick={handleMarkAllAsRead}
>
<CheckCheck className="mr-1 h-3 w-3" />
Mark all as read
</Button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center p-4">
<div className="border-primary h-6 w-6 animate-spin rounded-full border-b-2"></div>
</div>
) : notifications.length > 0 ? (
<div>
{notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"hover:bg-muted/50 flex cursor-pointer border-b p-3 transition-colors",
!notification.isRead &&
"bg-blue-50 dark:bg-blue-900/10"
)}
>
<div className="mr-3 mt-0.5">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium">
{notification.title}
</h4>
<div className="flex items-center">
{!notification.isRead && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() =>
handleMarkAsRead(notification.id)
}
>
<Check className="h-3 w-3" />
</Button>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
{notification.message}
</p>
<span className="text-muted-foreground mt-1 block text-xs">
{formatDistanceToNow(
new Date(notification.timestamp),
{ addSuffix: true }
)}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground p-4 text-center text-sm">
No notifications
</p>
)}
</div>
<div className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={handleViewAllNotifications}
>
View all notifications
</Button>
</div>
</div>
)}
</div>
{/* User Menu */}
<div className="relative">
<Button
variant="ghost"
className="flex items-center space-x-2 rounded-full"
onClick={() => setProfileOpen(!profileOpen)}
>
<div className="bg-primary text-primary-foreground flex h-8 w-8 items-center justify-center rounded-full font-medium">
{session.user.name?.charAt(0)}
</div>
<div className="hidden flex-col items-start md:flex">
<span className="text-sm font-medium">{session.user.name}</span>
<span className="text-muted-foreground text-xs capitalize">
{role}
</span>
</div>
<ChevronDown
className={`text-muted-foreground hidden h-4 w-4 transition-transform duration-200 md:block ${
profileOpen ? "rotate-180 transform" : ""
}`}
/>
</Button>
{/* Profile Dropdown */}
{profileOpen && (
<div className="bg-background absolute right-0 z-50 mt-2 w-56 rounded-md shadow-lg ring-1 ring-black ring-opacity-5">
<div className="border-b px-4 py-3">
<p className="text-sm font-medium">{session.user.name}</p>
<p className="text-muted-foreground text-xs">
{session.user.email}
</p>
</div>
<div className="py-1">
<Button
variant="ghost"
onClick={() => handleMenuItemClick("/dashboard/settings")}
className="text-foreground flex w-full items-center justify-start px-4 py-2 text-sm font-normal"
>
<User className="mr-3 h-4 w-4" />
Profile
</Button>
<Button
variant="ghost"
onClick={() => handleMenuItemClick("/dashboard/settings")}
className="text-foreground flex w-full items-center justify-start px-4 py-2 text-sm font-normal"
>
<Settings className="mr-3 h-4 w-4" />
Settings
</Button>
<Button
variant="ghost"
onClick={() => {
window.open("https://wa.me/1234567890", "_blank");
}}
className="text-foreground flex w-full items-center justify-start px-4 py-2 text-sm font-normal"
>
<HelpCircle className="mr-3 h-4 w-4" />
Help & Support
</Button>
</div>
<div className="border-t py-1">
<Button
variant="ghost"
onClick={handleLogout}
className="text-destructive hover:bg-destructive/10 flex w-full items-center justify-start px-4 py-2 text-sm font-normal"
>
<LogOut className="mr-3 h-4 w-4" />
Logout
</Button>
</div>
</div>
)}
</div>
</div>
</header>
);
}
Step 3 : Create the Sidebar Component
"use client";
import type React from "react";
import { useState, useEffect } from "react";
import { Package, ChevronLeft, ChevronRight, LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { navItems } from "@/config/sidebar";
export const Sidebar: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const pathname = usePathname();
const { data: session } = useSession();
useEffect(() => {
document.documentElement.style.setProperty(
"--sidebar-width",
collapsed ? "64px" : "256px"
);
}, [collapsed]);
const handleToggle = () => {
setCollapsed(!collapsed);
};
// Correct isActive logic to distinguish sub-paths
const isActive = (path: string) => {
if (path === "/dashboard") {
return pathname === "/dashboard"; // Only active when exactly /dashboard
}
return pathname.startsWith(path); // Active for other nested paths
};
// Filter navigation items based on user permissions
const filteredNavItems = navItems.filter((item) => {
if (!session?.user?.permissions) return false;
return session.user.permissions.includes(item.permission);
});
return (
<aside
className={`fixed left-0 top-0 z-50 flex h-full flex-col bg-[#0F2557] text-white shadow-xl transition-all duration-300 ${
collapsed ? "w-16" : "w-64"
}`}
>
{/* Header */}
<div
className={`border-b border-blue-600/20 p-4 ${collapsed ? "px-2" : "px-4"}`}
>
<div
className={`flex ${collapsed ? "justify-center" : "justify-between"} items-center`}
>
{!collapsed && (
<div className="flex items-center">
<div className="mr-3 rounded-lg bg-blue-500 p-2">
<Package size={20} className="text-white" />
</div>
<h1 className="text-xl font-bold text-white">TrakIT</h1>
</div>
)}
{collapsed && (
<div className="rounded-lg bg-blue-500 p-2">
<Package size={20} className="text-white" />
</div>
)}
<button
onClick={handleToggle}
className="rounded-full p-1.5 text-blue-200 transition-colors duration-200 hover:bg-blue-600/50 hover:text-white"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</div>
{/* Navigation - Now using filtered nav items based on permissions */}
<nav className="flex-1 py-2">
<div className={`space-y-1 ${collapsed ? "px-2" : "px-3"}`}>
{filteredNavItems.map((item) => (
<Link
key={item.path}
href={item.path}
className={`group flex items-center ${
collapsed ? "justify-center px-2" : "justify-start px-3"
} relative rounded-lg py-3 transition-all duration-200 ${
isActive(item.path)
? "border-l-4 border-blue-300 bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/30"
: "text-blue-100 hover:bg-blue-700/50 hover:text-white hover:shadow-md"
}`}
title={collapsed ? item.name : undefined}
>
<span className={`flex-shrink-0 ${!collapsed ? "mr-3" : ""}`}>
{item.icon}
</span>
{!collapsed && (
<span className="text-sm font-medium">{item.name}</span>
)}
{/* Active indicator for collapsed state */}
{isActive(item.path) && collapsed && (
<div className="absolute bottom-0 right-0 top-0 w-1 rounded-l-full bg-blue-300"></div>
)}
{/* Active indicator for expanded state */}
{isActive(item.path) && !collapsed && (
<div className="ml-auto h-2 w-2 animate-pulse rounded-full bg-blue-200"></div>
)}
</Link>
))}
</div>
</nav>
{/* Footer - Logout */}
<div
className={`mt-auto border-t border-blue-600/20 p-4 ${collapsed ? "px-2" : "px-4"}`}
>
<div className="space-y-2">
<button
className={`group flex items-center text-sm ${
collapsed ? "justify-center px-2" : "justify-start px-3"
} w-full rounded-lg py-3 text-gray-200 transition-all duration-200 hover:border-red-400/40 hover:bg-red-500/20 hover:text-red-100`}
title={collapsed ? "Logout" : undefined}
>
<LogOut
size={20}
className={`${!collapsed ? "mr-3" : ""} transition-transform group-hover:scale-110`}
/>
{!collapsed && <span className="font-medium">Logout</span>}
</button>
</div>
</div>
</aside>
);
};
export default Sidebar;
Step 4 : Create the Sidebar-Config file in the config folder
import {
Truck,
FileText,
BarChart3,
Users,
Settings,
Package,
Anchor,
PlaneLanding,
MessageCircle,
MessageCircleCode,
Shield,
UserCheck,
} from "lucide-react";
import type { JSX } from "react";
export interface ISidebarLink {
name: string;
path: string;
icon: JSX.Element;
permission: string; // Required permission to view this item
roles?: string[]; // Keep for backward compatibility
}
export const navItems: ISidebarLink[] = [
{
name: "Dashboard",
path: "/dashboard",
icon: <BarChart3 size={20} />,
permission: "dashboard.read",
roles: ["ADMIN", "STAFF", "AGENT", "USER"],
},
{
name: "Customers",
path: "/dashboard/customers",
icon: <UserCheck size={20} />,
permission: "customers.read",
roles: ["ADMIN", "STAFF", "AGENT"],
},
{
name: "Shipments",
path: "/dashboard/shipments-trakit",
icon: <Package size={20} />,
permission: "shipments.read",
roles: ["ADMIN", "STAFF", "AGENT"],
},
{
name: "Sea Freight",
path: "/dashboard/sea-freights",
icon: <Anchor size={20} />,
permission: "sea_freight.read",
roles: ["ADMIN", "STAFF", "AGENT"],
},
{
name: "Air Freight",
path: "/dashboard/air-freight",
icon: <PlaneLanding size={20} />,
permission: "air_freight.read",
roles: ["ADMIN", "STAFF", "AGENT"],
},
{
name: "Documents",
path: "/dashboard/documents",
icon: <FileText size={20} />,
permission: "documents.read",
roles: ["ADMIN", "STAFF", "AGENT"],
},
{
name: "Tracking",
path: "/dashboard/tracking",
icon: <Truck size={20} />,
permission: "tracking.read",
roles: ["ADMIN", "STAFF", "AGENT", "USER"],
},
{
name: "Alert Panel",
path: "/dashboard/panel",
icon: <MessageCircleCode size={20} />,
permission: "alert-panel.read",
roles: ["ADMIN"],
},
{
name: "Notifications",
path: "/dashboard/notifications",
icon: <MessageCircle size={20} />,
permission: "notifications.read",
roles: ["ADMIN", "STAFF", "AGENT", "USER"],
},
{
name: "Users",
path: "/dashboard/users",
icon: <Users size={20} />,
permission: "users.read",
roles: ["ADMIN"],
},
{
name: "Roles",
path: "/dashboard/roles",
icon: <Shield size={20} />,
permission: "roles.read",
roles: ["ADMIN"],
},
{
name: "Settings",
path: "/dashboard/settings",
icon: <Settings size={20} />,
permission: "settings.read",
roles: ["ADMIN"],
},
];
Step 5 : Modify the Sidebar filter function accordingly
const filteredNavItems = navItems.filter((item) => {
if (!session?.user?.roles) return false;
return session.user.roles.includes(role);
});
Update your global for styles to use primary and secondary colors
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Updated primary to your dark blue #0C2D4A */
--primary: oklch(0.25 0.08 240);
--primary-foreground: oklch(0.985 0 0);
/* Updated secondary to your yellow #FFD300 */
--secondary: oklch(0.85 0.15 85);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* Updated accent to complement your brand colors */
--accent: oklch(0.95 0.02 85);
--accent-foreground: oklch(0.25 0.08 240);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.25 0.08 240);
--chart-1: oklch(0.25 0.08 240);
--chart-2: oklch(0.85 0.15 85);
--chart-3: oklch(0.35 0.12 240);
--chart-4: oklch(0.75 0.12 85);
--chart-5: oklch(0.45 0.15 240);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.25 0.08 240);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.25 0.08 240);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
/* Updated dark mode primary to lighter version of your blue */
--primary: oklch(0.45 0.12 240);
--primary-foreground: oklch(0.985 0 0);
/* Updated dark mode secondary to slightly muted yellow */
--secondary: oklch(0.75 0.12 85);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
/* Updated dark mode accent */
--accent: oklch(0.35 0.08 240);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.45 0.12 240);
--chart-1: oklch(0.45 0.12 240);
--chart-2: oklch(0.75 0.12 85);
--chart-3: oklch(0.55 0.15 240);
--chart-4: oklch(0.65 0.1 85);
--chart-5: oklch(0.35 0.1 240);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.45 0.12 240);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/* CONVERT HEX TO OKLH : https://openreplay.com/tools/hex-to-oklch/ */