DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Building a User Dashboard in Next.js 14: Auth, Billing, and Profile Pages

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)