Tutorial: User Dashboard
Build a comprehensive user dashboard with analytics, settings, and activity tracking.
What You'll Build#
- Dashboard overview with metrics
- Usage analytics charts
- Account settings page
- Activity feed
- Responsive sidebar navigation
Prerequisites#
- Next.js project with authentication (see Authentication Tutorial)
- Prisma with PostgreSQL
- Bootspring initialized
Time Required#
Approximately 40 minutes.
Step 1: Plan with Architecture Expert#
Start by consulting the architecture-expert:
bootspring agent invoke architecture-expert "Design a user dashboard with overview metrics, usage analytics, settings, and activity feed"The agent will provide:
- Component architecture
- Data requirements
- API endpoints
- State management approach
Step 2: Design the Database Schema#
Ask the database-expert:
bootspring agent invoke database-expert "Design schema for dashboard with usage tracking, activity logs, and user preferences"Update your Prisma schema:
1// prisma/schema.prisma
2
3model User {
4 id String @id @default(cuid())
5 clerkId String @unique
6 email String @unique
7 name String?
8 avatarUrl String?
9
10 // Subscription fields
11 tier String @default("free")
12 stripeCustomerId String? @unique
13
14 // Preferences
15 preferences Json @default("{}")
16
17 // Relations
18 projects Project[]
19 activities Activity[]
20 usageRecords UsageRecord[]
21
22 createdAt DateTime @default(now())
23 updatedAt DateTime @updatedAt
24}
25
26model Project {
27 id String @id @default(cuid())
28 name String
29 description String?
30 status String @default("active")
31
32 userId String
33 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
34
35 createdAt DateTime @default(now())
36 updatedAt DateTime @updatedAt
37
38 @@index([userId])
39}
40
41model Activity {
42 id String @id @default(cuid())
43 type String // "project_created", "agent_used", "skill_applied", etc.
44 metadata Json @default("{}")
45
46 userId String
47 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
48
49 createdAt DateTime @default(now())
50
51 @@index([userId, createdAt])
52}
53
54model UsageRecord {
55 id String @id @default(cuid())
56 type String // "api_call", "agent_invocation", etc.
57 count Int @default(1)
58 date DateTime @db.Date
59
60 userId String
61 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
62
63 createdAt DateTime @default(now())
64
65 @@unique([userId, type, date])
66 @@index([userId, date])
67}Run the migration:
npx prisma db pushStep 3: Create Dashboard Layout#
Ask the frontend-expert:
bootspring agent invoke frontend-expert "Create a dashboard layout with responsive sidebar, header with user menu, and main content area"Dashboard Layout#
1// app/(dashboard)/layout.tsx
2import { auth } from '@clerk/nextjs';
3import { redirect } from 'next/navigation';
4import { Sidebar } from '@/components/dashboard/Sidebar';
5import { DashboardHeader } from '@/components/dashboard/DashboardHeader';
6
7export default async function DashboardLayout({
8 children,
9}: {
10 children: React.ReactNode;
11}) {
12 const { userId } = auth();
13
14 if (!userId) {
15 redirect('/sign-in');
16 }
17
18 return (
19 <div className="flex h-screen bg-gray-50">
20 <Sidebar />
21 <div className="flex-1 flex flex-col overflow-hidden">
22 <DashboardHeader />
23 <main className="flex-1 overflow-y-auto p-6">
24 {children}
25 </main>
26 </div>
27 </div>
28 );
29}Sidebar Component#
1// components/dashboard/Sidebar.tsx
2'use client';
3
4import Link from 'next/link';
5import { usePathname } from 'next/navigation';
6import { cn } from '@/lib/utils';
7import {
8 LayoutDashboard,
9 FolderKanban,
10 BarChart3,
11 Settings,
12 Activity,
13 CreditCard,
14} from 'lucide-react';
15
16const navigation = [
17 { name: 'Overview', href: '/dashboard', icon: LayoutDashboard },
18 { name: 'Projects', href: '/dashboard/projects', icon: FolderKanban },
19 { name: 'Analytics', href: '/dashboard/analytics', icon: BarChart3 },
20 { name: 'Activity', href: '/dashboard/activity', icon: Activity },
21 { name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
22 { name: 'Settings', href: '/dashboard/settings', icon: Settings },
23];
24
25export function Sidebar() {
26 const pathname = usePathname();
27
28 return (
29 <aside className="hidden lg:flex lg:flex-col lg:w-64 lg:border-r lg:bg-white">
30 <div className="flex h-16 items-center px-6 border-b">
31 <Link href="/dashboard" className="text-xl font-bold">
32 MyApp
33 </Link>
34 </div>
35
36 <nav className="flex-1 px-4 py-4 space-y-1">
37 {navigation.map((item) => {
38 const isActive = pathname === item.href;
39
40 return (
41 <Link
42 key={item.name}
43 href={item.href}
44 className={cn(
45 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
46 isActive
47 ? 'bg-blue-50 text-blue-700'
48 : 'text-gray-600 hover:bg-gray-100'
49 )}
50 >
51 <item.icon className="h-5 w-5" />
52 {item.name}
53 </Link>
54 );
55 })}
56 </nav>
57
58 <div className="p-4 border-t">
59 <div className="rounded-lg bg-gray-50 p-4">
60 <p className="text-sm font-medium text-gray-900">Free Plan</p>
61 <p className="text-xs text-gray-500 mt-1">847 / 1,000 API calls</p>
62 <div className="mt-2 h-2 bg-gray-200 rounded-full overflow-hidden">
63 <div className="h-full bg-blue-600 w-[84.7%]" />
64 </div>
65 <Link
66 href="/pricing"
67 className="block mt-3 text-sm text-blue-600 hover:text-blue-700"
68 >
69 Upgrade Plan →
70 </Link>
71 </div>
72 </div>
73 </aside>
74 );
75}Dashboard Header#
1// components/dashboard/DashboardHeader.tsx
2import { UserButton } from '@clerk/nextjs';
3import { Bell, Search } from 'lucide-react';
4
5export function DashboardHeader() {
6 return (
7 <header className="h-16 border-b bg-white px-6 flex items-center justify-between">
8 <div className="flex items-center gap-4 flex-1">
9 <div className="relative max-w-md flex-1">
10 <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
11 <input
12 type="search"
13 placeholder="Search..."
14 className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
15 />
16 </div>
17 </div>
18
19 <div className="flex items-center gap-4">
20 <button className="p-2 hover:bg-gray-100 rounded-lg relative">
21 <Bell className="h-5 w-5 text-gray-600" />
22 <span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full" />
23 </button>
24 <UserButton afterSignOutUrl="/" />
25 </div>
26 </header>
27 );
28}Step 4: Create Dashboard Overview#
Dashboard API#
1// app/api/dashboard/stats/route.ts
2import { auth } from '@clerk/nextjs';
3import { NextResponse } from 'next/server';
4import { prisma } from '@/lib/prisma';
5import { startOfMonth, subDays } from 'date-fns';
6
7export async function GET() {
8 const { userId } = auth();
9
10 if (!userId) {
11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 }
13
14 const user = await prisma.user.findUnique({
15 where: { clerkId: userId },
16 });
17
18 if (!user) {
19 return NextResponse.json({ error: 'User not found' }, { status: 404 });
20 }
21
22 const now = new Date();
23 const monthStart = startOfMonth(now);
24 const thirtyDaysAgo = subDays(now, 30);
25
26 const [projectCount, apiCallsThisMonth, recentActivity, usageByDay] =
27 await Promise.all([
28 // Total projects
29 prisma.project.count({
30 where: { userId: user.id },
31 }),
32
33 // API calls this month
34 prisma.usageRecord.aggregate({
35 where: {
36 userId: user.id,
37 type: 'api_call',
38 date: { gte: monthStart },
39 },
40 _sum: { count: true },
41 }),
42
43 // Recent activity
44 prisma.activity.findMany({
45 where: { userId: user.id },
46 orderBy: { createdAt: 'desc' },
47 take: 5,
48 }),
49
50 // Usage by day (last 30 days)
51 prisma.usageRecord.groupBy({
52 by: ['date'],
53 where: {
54 userId: user.id,
55 type: 'api_call',
56 date: { gte: thirtyDaysAgo },
57 },
58 _sum: { count: true },
59 orderBy: { date: 'asc' },
60 }),
61 ]);
62
63 return NextResponse.json({
64 stats: {
65 projects: projectCount,
66 apiCalls: apiCallsThisMonth._sum.count || 0,
67 apiLimit: user.tier === 'free' ? 1000 : user.tier === 'pro' ? 10000 : 50000,
68 },
69 recentActivity,
70 usageByDay: usageByDay.map((day) => ({
71 date: day.date,
72 count: day._sum.count || 0,
73 })),
74 });
75}Dashboard Page#
1// app/(dashboard)/dashboard/page.tsx
2import { auth } from '@clerk/nextjs';
3import { redirect } from 'next/navigation';
4import { prisma } from '@/lib/prisma';
5import { StatsCards } from '@/components/dashboard/StatsCards';
6import { UsageChart } from '@/components/dashboard/UsageChart';
7import { RecentActivity } from '@/components/dashboard/RecentActivity';
8import { QuickActions } from '@/components/dashboard/QuickActions';
9
10export default async function DashboardPage() {
11 const { userId } = auth();
12
13 if (!userId) {
14 redirect('/sign-in');
15 }
16
17 const user = await prisma.user.findUnique({
18 where: { clerkId: userId },
19 include: {
20 projects: {
21 take: 5,
22 orderBy: { updatedAt: 'desc' },
23 },
24 activities: {
25 take: 10,
26 orderBy: { createdAt: 'desc' },
27 },
28 },
29 });
30
31 if (!user) {
32 redirect('/onboarding');
33 }
34
35 return (
36 <div className="space-y-6">
37 <div>
38 <h1 className="text-2xl font-bold">Dashboard</h1>
39 <p className="text-gray-600">Welcome back, {user.name || 'there'}!</p>
40 </div>
41
42 <StatsCards userId={user.id} tier={user.tier} />
43
44 <div className="grid lg:grid-cols-2 gap-6">
45 <UsageChart userId={user.id} />
46 <QuickActions />
47 </div>
48
49 <div className="grid lg:grid-cols-2 gap-6">
50 <RecentActivity activities={user.activities} />
51 <div className="bg-white rounded-lg border p-6">
52 <h3 className="font-semibold mb-4">Recent Projects</h3>
53 <div className="space-y-3">
54 {user.projects.map((project) => (
55 <div
56 key={project.id}
57 className="flex items-center justify-between py-2"
58 >
59 <div>
60 <p className="font-medium">{project.name}</p>
61 <p className="text-sm text-gray-500">{project.status}</p>
62 </div>
63 </div>
64 ))}
65 {user.projects.length === 0 && (
66 <p className="text-gray-500 text-sm">No projects yet</p>
67 )}
68 </div>
69 </div>
70 </div>
71 </div>
72 );
73}Stats Cards Component#
1// components/dashboard/StatsCards.tsx
2import { FolderKanban, Zap, TrendingUp, Clock } from 'lucide-react';
3
4interface StatsCardsProps {
5 userId: string;
6 tier: string;
7}
8
9export async function StatsCards({ userId, tier }: StatsCardsProps) {
10 // Fetch stats from API or directly
11 const response = await fetch(
12 `${process.env.NEXT_PUBLIC_APP_URL}/api/dashboard/stats`,
13 { cache: 'no-store' }
14 );
15 const data = await response.json();
16
17 const stats = [
18 {
19 name: 'Total Projects',
20 value: data.stats?.projects || 0,
21 icon: FolderKanban,
22 change: '+2 this month',
23 },
24 {
25 name: 'API Calls',
26 value: `${data.stats?.apiCalls || 0} / ${data.stats?.apiLimit || 1000}`,
27 icon: Zap,
28 change: `${tier} plan`,
29 },
30 {
31 name: 'Agents Used',
32 value: '12',
33 icon: TrendingUp,
34 change: 'Most used: frontend-expert',
35 },
36 {
37 name: 'Active Sessions',
38 value: '3',
39 icon: Clock,
40 change: '2 devices',
41 },
42 ];
43
44 return (
45 <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
46 {stats.map((stat) => (
47 <div
48 key={stat.name}
49 className="bg-white rounded-lg border p-6"
50 >
51 <div className="flex items-center justify-between">
52 <stat.icon className="h-5 w-5 text-gray-400" />
53 </div>
54 <p className="mt-4 text-2xl font-bold">{stat.value}</p>
55 <p className="text-sm text-gray-600">{stat.name}</p>
56 <p className="text-xs text-gray-500 mt-1">{stat.change}</p>
57 </div>
58 ))}
59 </div>
60 );
61}Step 5: Create Usage Analytics Chart#
1// components/dashboard/UsageChart.tsx
2'use client';
3
4import { useEffect, useState } from 'react';
5import {
6 LineChart,
7 Line,
8 XAxis,
9 YAxis,
10 CartesianGrid,
11 Tooltip,
12 ResponsiveContainer,
13} from 'recharts';
14
15interface UsageChartProps {
16 userId: string;
17}
18
19export function UsageChart({ userId }: UsageChartProps) {
20 const [data, setData] = useState<{ date: string; count: number }[]>([]);
21 const [loading, setLoading] = useState(true);
22
23 useEffect(() => {
24 async function fetchUsage() {
25 try {
26 const response = await fetch('/api/dashboard/stats');
27 const result = await response.json();
28 setData(result.usageByDay || []);
29 } catch (error) {
30 console.error('Failed to fetch usage data:', error);
31 } finally {
32 setLoading(false);
33 }
34 }
35
36 fetchUsage();
37 }, []);
38
39 if (loading) {
40 return (
41 <div className="bg-white rounded-lg border p-6 h-80 flex items-center justify-center">
42 <div className="animate-pulse text-gray-400">Loading...</div>
43 </div>
44 );
45 }
46
47 return (
48 <div className="bg-white rounded-lg border p-6">
49 <h3 className="font-semibold mb-4">API Usage (Last 30 Days)</h3>
50 <div className="h-64">
51 <ResponsiveContainer width="100%" height="100%">
52 <LineChart data={data}>
53 <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
54 <XAxis
55 dataKey="date"
56 tickFormatter={(value) =>
57 new Date(value).toLocaleDateString('en-US', {
58 month: 'short',
59 day: 'numeric',
60 })
61 }
62 tick={{ fontSize: 12 }}
63 />
64 <YAxis tick={{ fontSize: 12 }} />
65 <Tooltip
66 labelFormatter={(value) =>
67 new Date(value).toLocaleDateString('en-US', {
68 month: 'long',
69 day: 'numeric',
70 year: 'numeric',
71 })
72 }
73 />
74 <Line
75 type="monotone"
76 dataKey="count"
77 stroke="#2563eb"
78 strokeWidth={2}
79 dot={false}
80 />
81 </LineChart>
82 </ResponsiveContainer>
83 </div>
84 </div>
85 );
86}Install recharts:
npm install rechartsStep 6: Create Activity Feed#
1// components/dashboard/RecentActivity.tsx
2import { formatDistanceToNow } from 'date-fns';
3import {
4 FolderPlus,
5 Bot,
6 Code,
7 Shield,
8 Zap,
9 CheckCircle,
10} from 'lucide-react';
11
12interface Activity {
13 id: string;
14 type: string;
15 metadata: Record<string, unknown>;
16 createdAt: Date;
17}
18
19interface RecentActivityProps {
20 activities: Activity[];
21}
22
23const activityIcons: Record<string, React.ElementType> = {
24 project_created: FolderPlus,
25 agent_used: Bot,
26 skill_applied: Code,
27 quality_check: Shield,
28 workflow_completed: CheckCircle,
29 default: Zap,
30};
31
32const activityMessages: Record<string, (metadata: Record<string, unknown>) => string> = {
33 project_created: (m) => `Created project "${m.projectName}"`,
34 agent_used: (m) => `Used ${m.agentName} agent`,
35 skill_applied: (m) => `Applied ${m.skillName} skill`,
36 quality_check: (m) => `Ran ${m.gate} quality check`,
37 workflow_completed: (m) => `Completed ${m.workflowName} workflow`,
38 default: () => 'Activity recorded',
39};
40
41export function RecentActivity({ activities }: RecentActivityProps) {
42 return (
43 <div className="bg-white rounded-lg border p-6">
44 <h3 className="font-semibold mb-4">Recent Activity</h3>
45 <div className="space-y-4">
46 {activities.map((activity) => {
47 const Icon = activityIcons[activity.type] || activityIcons.default;
48 const getMessage =
49 activityMessages[activity.type] || activityMessages.default;
50
51 return (
52 <div key={activity.id} className="flex items-start gap-3">
53 <div className="p-2 bg-gray-100 rounded-lg">
54 <Icon className="h-4 w-4 text-gray-600" />
55 </div>
56 <div className="flex-1 min-w-0">
57 <p className="text-sm text-gray-900">
58 {getMessage(activity.metadata as Record<string, unknown>)}
59 </p>
60 <p className="text-xs text-gray-500">
61 {formatDistanceToNow(new Date(activity.createdAt), {
62 addSuffix: true,
63 })}
64 </p>
65 </div>
66 </div>
67 );
68 })}
69 {activities.length === 0 && (
70 <p className="text-sm text-gray-500">No recent activity</p>
71 )}
72 </div>
73 </div>
74 );
75}Step 7: Create Settings Page#
1// app/(dashboard)/dashboard/settings/page.tsx
2import { auth, currentUser } from '@clerk/nextjs';
3import { redirect } from 'next/navigation';
4import { prisma } from '@/lib/prisma';
5import { ProfileSettings } from '@/components/settings/ProfileSettings';
6import { NotificationSettings } from '@/components/settings/NotificationSettings';
7import { ApiKeySettings } from '@/components/settings/ApiKeySettings';
8
9export default async function SettingsPage() {
10 const { userId } = auth();
11
12 if (!userId) {
13 redirect('/sign-in');
14 }
15
16 const [clerkUser, dbUser] = await Promise.all([
17 currentUser(),
18 prisma.user.findUnique({
19 where: { clerkId: userId },
20 }),
21 ]);
22
23 if (!dbUser) {
24 redirect('/onboarding');
25 }
26
27 return (
28 <div className="max-w-3xl space-y-8">
29 <div>
30 <h1 className="text-2xl font-bold">Settings</h1>
31 <p className="text-gray-600">Manage your account preferences</p>
32 </div>
33
34 <ProfileSettings
35 user={{
36 name: clerkUser?.firstName || '',
37 email: clerkUser?.emailAddresses[0]?.emailAddress || '',
38 avatarUrl: clerkUser?.imageUrl || '',
39 }}
40 />
41
42 <NotificationSettings
43 preferences={dbUser.preferences as Record<string, boolean>}
44 />
45
46 <ApiKeySettings userId={dbUser.id} />
47 </div>
48 );
49}Step 8: Test the Dashboard#
-
Start the development server:
npm run dev -
Sign in and navigate to
/dashboard -
Verify:
- Sidebar navigation works
- Stats cards display data
- Usage chart renders
- Activity feed shows entries
- Settings page loads
Verification Checklist#
- Layout is responsive
- Protected routes work
- Data fetching is efficient
- Charts render correctly
- Navigation is intuitive
Security Review#
bootspring agent invoke security-expert "Review the dashboard implementation for security"What You Learned#
- Dashboard layout architecture
- Server components for data fetching
- Client components for interactivity
- Usage tracking implementation
- Settings management