Skip to main content
Back to Blog
Tutorials
5 min read
December 11, 2024

How to Build a Customer Portal with Next.js

Build a self-service customer portal with account management, billing history, support tickets, and project tracking in Next.js.

Ryel Banfield

Founder & Lead Developer

A customer portal lets clients manage their account, view invoices, and track projects without contacting support. Here is how to build one.

Portal Layout

// app/(portal)/layout.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { PortalSidebar } from "@/components/portal/PortalSidebar";

export default async function PortalLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();
  if (!session?.user) redirect("/login");

  return (
    <div className="flex min-h-screen">
      <PortalSidebar user={session.user} />
      <main className="flex-1 p-6 md:p-8 overflow-auto">{children}</main>
    </div>
  );
}

Portal Sidebar

"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

interface User {
  name: string;
  email: string;
  image?: string;
}

const navItems = [
  { label: "Overview", href: "/portal", icon: "grid" },
  { label: "Projects", href: "/portal/projects", icon: "folder" },
  { label: "Invoices", href: "/portal/invoices", icon: "receipt" },
  { label: "Support", href: "/portal/support", icon: "help" },
  { label: "Settings", href: "/portal/settings", icon: "settings" },
];

export function PortalSidebar({ user }: { user: User }) {
  const pathname = usePathname();

  return (
    <aside className="w-64 border-r bg-muted/30 p-4 hidden md:block">
      {/* User Info */}
      <div className="flex items-center gap-3 mb-8 pb-4 border-b">
        <div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-bold">
          {user.name?.[0]?.toUpperCase() ?? "?"}
        </div>
        <div className="min-w-0">
          <p className="text-sm font-medium truncate">{user.name}</p>
          <p className="text-xs text-muted-foreground truncate">{user.email}</p>
        </div>
      </div>

      {/* Navigation */}
      <nav>
        <ul className="space-y-1">
          {navItems.map((item) => {
            const isActive =
              item.href === "/portal"
                ? pathname === "/portal"
                : pathname.startsWith(item.href);

            return (
              <li key={item.href}>
                <Link
                  href={item.href}
                  className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
                    isActive
                      ? "bg-primary text-primary-foreground"
                      : "text-muted-foreground hover:bg-muted hover:text-foreground"
                  }`}
                >
                  {item.label}
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>
    </aside>
  );
}

Portal Overview

// app/(portal)/portal/page.tsx
import { auth } from "@/lib/auth";
import { getCustomerSummary } from "@/lib/portal-service";
import Link from "next/link";

export default async function PortalOverview() {
  const session = await auth();
  const summary = await getCustomerSummary(session!.user.id);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">
        Welcome back, {session!.user.name?.split(" ")[0]}
      </h1>

      {/* Quick Stats */}
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
        <StatCard label="Active Projects" value={summary.activeProjects} />
        <StatCard label="Open Tickets" value={summary.openTickets} />
        <StatCard
          label="Outstanding Balance"
          value={`$${(summary.outstandingBalance / 100).toFixed(2)}`}
        />
        <StatCard label="Total Invoices" value={summary.totalInvoices} />
      </div>

      {/* Recent Activity */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <div className="border rounded-lg p-4">
          <div className="flex items-center justify-between mb-4">
            <h2 className="font-semibold">Recent Projects</h2>
            <Link href="/portal/projects" className="text-sm text-primary hover:underline">
              View all
            </Link>
          </div>
          <ul className="space-y-3">
            {summary.recentProjects.map((project) => (
              <li key={project.id} className="flex items-center justify-between">
                <div>
                  <p className="text-sm font-medium">{project.name}</p>
                  <p className="text-xs text-muted-foreground">
                    {project.status}
                  </p>
                </div>
                <ProgressBadge progress={project.progress} />
              </li>
            ))}
          </ul>
        </div>

        <div className="border rounded-lg p-4">
          <div className="flex items-center justify-between mb-4">
            <h2 className="font-semibold">Recent Invoices</h2>
            <Link href="/portal/invoices" className="text-sm text-primary hover:underline">
              View all
            </Link>
          </div>
          <ul className="space-y-3">
            {summary.recentInvoices.map((invoice) => (
              <li key={invoice.id} className="flex items-center justify-between">
                <div>
                  <p className="text-sm font-medium">#{invoice.number}</p>
                  <p className="text-xs text-muted-foreground">
                    {new Date(invoice.date).toLocaleDateString()}
                  </p>
                </div>
                <div className="text-right">
                  <p className="text-sm font-medium">
                    ${(invoice.amount / 100).toFixed(2)}
                  </p>
                  <InvoiceStatusBadge status={invoice.status} />
                </div>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}

function StatCard({ label, value }: { label: string; value: string | number }) {
  return (
    <div className="border rounded-lg p-4">
      <p className="text-sm text-muted-foreground">{label}</p>
      <p className="text-2xl font-bold mt-1">{value}</p>
    </div>
  );
}

function ProgressBadge({ progress }: { progress: number }) {
  return (
    <div className="flex items-center gap-2">
      <div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
        <div
          className="h-full bg-primary rounded-full"
          style={{ width: `${progress}%` }}
        />
      </div>
      <span className="text-xs text-muted-foreground">{progress}%</span>
    </div>
  );
}

function InvoiceStatusBadge({ status }: { status: string }) {
  const styles: Record<string, string> = {
    paid: "text-green-600",
    pending: "text-yellow-600",
    overdue: "text-red-600",
  };
  return (
    <span className={`text-xs font-medium ${styles[status] ?? ""}`}>
      {status}
    </span>
  );
}

Support Ticket System

// app/(portal)/portal/support/page.tsx
import { auth } from "@/lib/auth";
import { getTickets } from "@/lib/portal-service";
import { NewTicketForm } from "@/components/portal/NewTicketForm";

export default async function SupportPage() {
  const session = await auth();
  const tickets = await getTickets(session!.user.id);

  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold">Support</h1>
        <NewTicketForm />
      </div>

      <div className="space-y-3">
        {tickets.map((ticket) => (
          <a
            key={ticket.id}
            href={`/portal/support/${ticket.id}`}
            className="block p-4 border rounded-lg hover:bg-muted/50 transition-colors"
          >
            <div className="flex items-start justify-between">
              <div>
                <h3 className="font-medium">{ticket.subject}</h3>
                <p className="text-sm text-muted-foreground mt-1 line-clamp-1">
                  {ticket.lastMessage}
                </p>
              </div>
              <div className="text-right shrink-0 ml-4">
                <TicketStatusBadge status={ticket.status} />
                <p className="text-xs text-muted-foreground mt-1">
                  {new Date(ticket.updatedAt).toLocaleDateString()}
                </p>
              </div>
            </div>
          </a>
        ))}
      </div>
    </div>
  );
}

function TicketStatusBadge({ status }: { status: string }) {
  const styles: Record<string, string> = {
    open: "bg-blue-100 text-blue-700",
    "in-progress": "bg-yellow-100 text-yellow-700",
    resolved: "bg-green-100 text-green-700",
    closed: "bg-gray-100 text-gray-700",
  };

  return (
    <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? ""}`}>
      {status}
    </span>
  );
}

New Ticket Form

"use client";

import { useState } from "react";

export function NewTicketForm() {
  const [open, setOpen] = useState(false);
  const [subject, setSubject] = useState("");
  const [message, setMessage] = useState("");
  const [priority, setPriority] = useState("normal");
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitting(true);

    await fetch("/api/portal/tickets", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ subject, message, priority }),
    });

    setOpen(false);
    setSubject("");
    setMessage("");
    setSubmitting(false);
    window.location.reload();
  }

  if (!open) {
    return (
      <button
        onClick={() => setOpen(true)}
        className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"
      >
        New Ticket
      </button>
    );
  }

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
      <form
        onSubmit={handleSubmit}
        className="bg-background rounded-lg p-6 w-full max-w-md space-y-4"
      >
        <h2 className="text-lg font-semibold">New Support Ticket</h2>

        <div>
          <label className="block text-sm font-medium mb-1">Subject</label>
          <input
            type="text"
            value={subject}
            onChange={(e) => setSubject(e.target.value)}
            required
            className="w-full px-3 py-2 border rounded-md"
            placeholder="Brief description of your issue"
          />
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">Priority</label>
          <select
            value={priority}
            onChange={(e) => setPriority(e.target.value)}
            className="w-full px-3 py-2 border rounded-md bg-background"
          >
            <option value="low">Low</option>
            <option value="normal">Normal</option>
            <option value="high">High</option>
            <option value="urgent">Urgent</option>
          </select>
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">Message</label>
          <textarea
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            required
            rows={4}
            className="w-full px-3 py-2 border rounded-md resize-none"
            placeholder="Describe your issue in detail..."
          />
        </div>

        <div className="flex justify-end gap-2">
          <button
            type="button"
            onClick={() => setOpen(false)}
            className="px-4 py-2 border rounded-md text-sm"
          >
            Cancel
          </button>
          <button
            type="submit"
            disabled={submitting}
            className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
          >
            {submitting ? "Submitting..." : "Submit Ticket"}
          </button>
        </div>
      </form>
    </div>
  );
}

Need a Customer Portal?

We build self-service portals with billing, project tracking, and support integrations. Contact us to create your customer portal.

customer portaldashboardauthbillingNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles