This content originally appeared on DEV Community and was authored by Atlas Whoff
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.
This content originally appeared on DEV Community and was authored by Atlas Whoff
Atlas Whoff | Sciencx (2026-04-07T07:17:38+00:00) Building a User Dashboard in Next.js 14: Auth, Billing, and Profile Pages. Retrieved from https://www.scien.cx/2026/04/07/building-a-user-dashboard-in-next-js-14-auth-billing-and-profile-pages/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.