The Dashboard Every SaaS Needs
Your landing page converts. Your Stripe checkout works. But users sign up and then what? They see a blank screen.
A production user dashboard needs: protected routes, profile management, usage stats, billing management, and a clean nav. Here's how to build it in Next.js 14.
Route Structure
app/
dashboard/
layout.tsx # Auth check + sidebar
page.tsx # Overview/home
profile/page.tsx # User settings
billing/page.tsx # Stripe portal
settings/page.tsx # App preferences
Auth Guard in Layout
The dashboard layout runs the auth check once for all child routes:
// app/dashboard/layout.tsx
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import DashboardNav from '@/components/DashboardNav'
export default async function DashboardLayout({
children
}: {
children: React.ReactNode
}) {
const session = await getServerSession(authOptions)
if (!session) {
redirect('/login')
}
return (
<div className="flex h-screen">
<DashboardNav user={session.user} />
<main className="flex-1 overflow-auto p-8">
{children}
</main>
</div>
)
}
Dashboard Overview Page
// app/dashboard/page.tsx
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { db } from '@/lib/db'
import StatsCard from '@/components/StatsCard'
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
const user = await db.user.findUnique({
where: { email: session!.user!.email! },
include: { subscription: true, _count: { select: { apiCalls: true } } }
})
return (
<div>
<h1 className="text-2xl font-bold mb-6">
Welcome back, {session?.user?.name?.split(' ')[0]}
</h1>
<div className="grid grid-cols-3 gap-6 mb-8">
<StatsCard
title="Plan"
value={user?.subscription?.plan ?? 'Free'}
/>
<StatsCard
title="API Calls"
value={user?._count.apiCalls ?? 0}
/>
<StatsCard
title="Member Since"
value={user?.createdAt.toLocaleDateString()}
/>
</div>
</div>
)
}
Sidebar Navigation
// components/DashboardNav.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { LayoutDashboard, User, CreditCard, Settings, LogOut } from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard },
{ href: '/dashboard/profile', label: 'Profile', icon: User },
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
]
export default function DashboardNav({ user }: { user: any }) {
const pathname = usePathname()
return (
<nav className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="p-6 border-b border-gray-800">
<p className="text-sm text-gray-400">{user.email}</p>
</div>
<div className="flex-1 p-4">
{navItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 text-sm
${ pathname === href
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<Icon size={16} />
{label}
</Link>
))}
</div>
<div className="p-4 border-t border-gray-800">
<button
onClick={() => signOut()}
className="flex items-center gap-3 px-3 py-2 text-sm text-gray-400 hover:text-white w-full"
>
<LogOut size={16} />
Sign out
</button>
</div>
</nav>
)
}
Billing Page with Stripe Portal
// app/dashboard/billing/page.tsx
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { db } from '@/lib/db'
import { stripe } from '@/lib/stripe'
import ManageBillingButton from './ManageBillingButton'
export default async function BillingPage() {
const session = await getServerSession(authOptions)
const user = await db.user.findUnique({
where: { email: session!.user!.email! },
include: { subscription: true }
})
const plan = user?.subscription?.plan ?? 'Free'
const nextBillDate = user?.subscription?.currentPeriodEnd
return (
<div>
<h1 className="text-2xl font-bold mb-6">Billing</h1>
<div className="bg-gray-800 rounded-lg p-6 mb-6">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-400">Current Plan</p>
<p className="text-xl font-semibold">{plan}</p>
{nextBillDate && (
<p className="text-sm text-gray-400 mt-1">
Next billing: {nextBillDate.toLocaleDateString()}
</p>
)}
</div>
<ManageBillingButton />
</div>
</div>
</div>
)
}
Manage Billing Button (Server Action)
// app/dashboard/billing/ManageBillingButton.tsx
'use client'
import { useState } from 'react'
export default function ManageBillingButton() {
const [loading, setLoading] = useState(false)
const handleManageBilling = async () => {
setLoading(true)
const res = await fetch('/api/billing/portal', { method: 'POST' })
const { url } = await res.json()
window.location.href = url
}
return (
<button
onClick={handleManageBilling}
disabled={loading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm"
>
{loading ? 'Loading...' : 'Manage Billing'}
</button>
)
}
Stripe Portal API Route
// app/api/billing/portal/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = await db.user.findUnique({
where: { email: session.user!.email! }
})
const portalSession = await stripe.billingPortal.sessions.create({
customer: user!.stripeCustomerId!,
return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`
})
return NextResponse.json({ url: portalSession.url })
}
Profile Page
// app/dashboard/profile/page.tsx
'use client'
import { useSession } from 'next-auth/react'
import { useState } from 'react'
export default function ProfilePage() {
const { data: session } = useSession()
const [name, setName] = useState(session?.user?.name ?? '')
const [saving, setSaving] = useState(false)
const handleSave = async () => {
setSaving(true)
await fetch('/api/user/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
setSaving(false)
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Profile</h1>
<div className="max-w-md space-y-4">
<div>
<label className="text-sm text-gray-400">Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full mt-1 px-3 py-2 bg-gray-800 rounded-lg"
/>
</div>
<div>
<label className="text-sm text-gray-400">Email</label>
<p className="mt-1 text-gray-300">{session?.user?.email}</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
)
}
This is Already Built
The AI SaaS Starter Kit ships with all of this pre-configured:
- Dashboard layout with auth guard
- Sidebar nav with active state
- Profile page with edit functionality
- Billing page with Stripe Portal integration
- Settings page
- Stats cards component
- All routes protected
$99 one-time at whoffagents.com
Skip the 2 days of dashboard wiring. It's done.
Top comments (0)