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#

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 push

Step 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}
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 recharts

Step 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#

  1. Start the development server:

    npm run dev
  2. Sign in and navigate to /dashboard

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

Next Steps#