Back to Blog

SaaS Customer Portal in 2026: Invoices, Usage Dashboards, and Self-Serve Team Management

Build a production SaaS customer portal: Stripe invoice history, usage metering dashboard, self-serve team member management, API key generation, and account settings.

Viprasol Tech Team
February 1, 2027
13 min read

SaaS Customer Portal in 2026: Invoices, Usage Dashboards, and Self-Serve Team Management

Every SaaS product needs a customer portal โ€” the place where customers manage their subscription, download invoices, see their usage, add or remove team members, and manage API keys. Done well, it eliminates most billing-related support tickets. Done poorly, it generates them.

This post builds the complete customer portal: Stripe invoice history with PDF download, usage metering with limits and overage warnings, self-serve team management (invite, role change, remove), and API key generation with masked display. All secured behind role-based access control.


Portal Architecture

The customer portal is a protected section of your app, typically at /settings or /account. It requires admin-or-owner access and is organized into tabs:

/settings
  /billing     โ†’ Subscription plan, invoices, payment method
  /usage       โ†’ Current period usage vs limits
  /team        โ†’ Member list, invitations, role management
  /api-keys    โ†’ API key generation and management
  /profile     โ†’ Account name, timezone, branding

Billing Tab: Invoice History

// app/settings/billing/page.tsx
import { getCurrentUser } from "@/lib/auth";
import { getPermissionContext } from "@/lib/auth/permission-context";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { InvoiceList } from "@/components/portal/InvoiceList";
import { PlanSummary } from "@/components/portal/PlanSummary";

export default async function BillingPage() {
  const ctx = await getPermissionContext();
  if (!ctx) redirect("/login");
  if (!ctx.can("read", "billing")) redirect("/settings");

  const subscription = await db.subscription.findFirst({
    where: { teamId: ctx.teamId, status: { in: ["active", "past_due", "trialing"] } },
  });

  if (!subscription?.stripeCustomerId) {
    return <NoBillingSetup />;
  }

  // Fetch last 12 invoices from Stripe
  const invoices = await stripe.invoices.list({
    customer: subscription.stripeCustomerId,
    limit: 12,
  });

  const stripeSubscription = subscription.stripeSubscriptionId
    ? await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
    : null;

  return (
    <div className="space-y-8">
      <PlanSummary
        plan={subscription.plan}
        status={stripeSubscription?.status ?? "active"}
        currentPeriodEnd={stripeSubscription?.current_period_end}
        cancelAtPeriodEnd={stripeSubscription?.cancel_at_period_end ?? false}
      />
      <InvoiceList invoices={invoices.data} />
    </div>
  );
}
// components/portal/InvoiceList.tsx
"use client";

import Stripe from "stripe";
import { formatCurrency } from "@/lib/utils";
import { Download, ExternalLink } from "lucide-react";

const STATUS_STYLES = {
  paid:   "bg-green-50 text-green-700",
  open:   "bg-yellow-50 text-yellow-700",
  void:   "bg-gray-100 text-gray-500",
  uncollectible: "bg-red-50 text-red-700",
};

export function InvoiceList({ invoices }: { invoices: Stripe.Invoice[] }) {
  if (invoices.length === 0) {
    return (
      <div className="text-center py-8 text-gray-400 text-sm">
        No invoices yet.
      </div>
    );
  }

  return (
    <div>
      <h3 className="text-sm font-semibold text-gray-900 mb-3">Invoice History</h3>
      <div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
        {invoices.map((invoice) => (
          <div key={invoice.id} className="flex items-center gap-4 px-4 py-3">
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium text-gray-900">
                {new Date((invoice.created) * 1000).toLocaleDateString("en-US", {
                  month: "long",
                  year: "numeric",
                })}
              </p>
              <p className="text-xs text-gray-400 mt-0.5">
                #{invoice.number ?? invoice.id.slice(-8).toUpperCase()}
              </p>
            </div>

            <span className={`text-xs font-medium px-2 py-0.5 rounded-full capitalize ${STATUS_STYLES[invoice.status as keyof typeof STATUS_STYLES] ?? ""}`}>
              {invoice.status}
            </span>

            <p className="text-sm font-semibold text-gray-900 w-20 text-right">
              {formatCurrency(invoice.total)}
            </p>

            <div className="flex gap-1">
              {invoice.invoice_pdf && (
                <a
                  href={invoice.invoice_pdf}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
                  title="Download PDF"
                >
                  <Download className="h-4 w-4" />
                </a>
              )}
              {invoice.hosted_invoice_url && (
                <a
                  href={invoice.hosted_invoice_url}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
                  title="View invoice"
                >
                  <ExternalLink className="h-4 w-4" />
                </a>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Usage Tab: Metering Dashboard

// lib/usage/metering.ts
import { db } from "@/lib/db";
import { PLANS } from "@/lib/billing/plans";
import { cache } from "react";

export interface UsageMetric {
  resource: string;
  label: string;
  used: number;
  limit: number;     // -1 = unlimited
  unit: string;
  warningThreshold: number;  // 0.8 = warn at 80%
}

export const getTeamUsage = cache(async (teamId: string, planId: string): Promise<UsageMetric[]> => {
  const plan = PLANS[planId];
  if (!plan) return [];

  const [seatCount, projectCount, storageBytes, apiCallsThisMonth] = await Promise.all([
    db.teamMember.count({ where: { teamId } }),
    db.project.count({ where: { teamId, deletedAt: null } }),
    db.fileUpload.aggregate({
      where: { teamId },
      _sum: { sizeBytes: true },
    }),
    db.apiLog.count({
      where: {
        teamId,
        createdAt: { gte: startOfCurrentMonth() },
      },
    }),
  ]);

  const storageGb = (storageBytes._sum.sizeBytes ?? 0) / (1024 ** 3);

  return [
    {
      resource: "seats",
      label: "Team members",
      used: seatCount,
      limit: plan.limits.seats,
      unit: "members",
      warningThreshold: 0.8,
    },
    {
      resource: "projects",
      label: "Projects",
      used: projectCount,
      limit: plan.limits.projects,
      unit: "projects",
      warningThreshold: 0.9,
    },
    {
      resource: "storage",
      label: "Storage",
      used: Math.round(storageGb * 10) / 10,
      limit: plan.limits.storageGb,
      unit: "GB",
      warningThreshold: 0.85,
    },
    {
      resource: "api_calls",
      label: "API calls this month",
      used: apiCallsThisMonth,
      limit: plan.limits.apiCallsPerMonth,
      unit: "calls",
      warningThreshold: 0.9,
    },
  ];
});

function startOfCurrentMonth(): Date {
  const d = new Date();
  d.setDate(1);
  d.setHours(0, 0, 0, 0);
  return d;
}
// components/portal/UsageDashboard.tsx
"use client";

import type { UsageMetric } from "@/lib/usage/metering";

function UsageBar({ metric }: { metric: UsageMetric }) {
  const isUnlimited = metric.limit === -1;
  const pct = isUnlimited ? 0 : Math.min((metric.used / metric.limit) * 100, 100);
  const isWarning = !isUnlimited && pct >= metric.warningThreshold * 100;
  const isCritical = !isUnlimited && pct >= 95;

  return (
    <div className="space-y-1.5">
      <div className="flex items-center justify-between">
        <span className="text-sm font-medium text-gray-700">{metric.label}</span>
        <span className="text-sm text-gray-500">
          {metric.used.toLocaleString()} {metric.unit}
          {!isUnlimited && (
            <span className="text-gray-400"> / {metric.limit.toLocaleString()}</span>
          )}
          {isUnlimited && <span className="text-gray-400"> / Unlimited</span>}
        </span>
      </div>
      {!isUnlimited && (
        <div className="h-2 bg-gray-100 rounded-full overflow-hidden">
          <div
            className={`h-full rounded-full transition-all ${
              isCritical ? "bg-red-500" : isWarning ? "bg-yellow-500" : "bg-blue-500"
            }`}
            style={{ width: `${pct}%` }}
          />
        </div>
      )}
      {isCritical && (
        <p className="text-xs text-red-600">
          You're approaching your limit. <a href="/settings/billing" className="underline">Upgrade your plan</a>
        </p>
      )}
    </div>
  );
}

export function UsageDashboard({ metrics }: { metrics: UsageMetric[] }) {
  return (
    <div className="space-y-5">
      <h3 className="text-sm font-semibold text-gray-900">Current Period Usage</h3>
      <div className="space-y-4">
        {metrics.map((metric) => (
          <UsageBar key={metric.resource} metric={metric} />
        ))}
      </div>
    </div>
  );
}

API Keys Management

// lib/api-keys/service.ts
import { db } from "@/lib/db";
import { randomBytes, createHash } from "crypto";

const KEY_PREFIX = "vpr_";  // Identifiable prefix for your product

export async function createApiKey(teamId: string, userId: string, label: string) {
  const rawKey = `${KEY_PREFIX}${randomBytes(32).toString("hex")}`;
  const hash = createHash("sha256").update(rawKey).digest("hex");

  // Store only the hash โ€” the raw key is shown once and never stored
  const record = await db.apiKey.create({
    data: {
      teamId,
      createdBy: userId,
      label,
      keyHash: hash,
      keyPrefix: rawKey.slice(0, 12), // Show first 12 chars for identification
    },
  });

  // Return the raw key ONCE โ€” caller must show it to the user now
  return { id: record.id, rawKey, prefix: record.keyPrefix };
}

export async function listApiKeys(teamId: string) {
  return db.apiKey.findMany({
    where: { teamId, revokedAt: null },
    select: {
      id: true,
      label: true,
      keyPrefix: true,
      lastUsedAt: true,
      createdAt: true,
      createdBy: { select: { name: true } },
    },
    orderBy: { createdAt: "desc" },
  });
}

export async function revokeApiKey(teamId: string, keyId: string) {
  await db.apiKey.updateMany({
    where: { id: keyId, teamId }, // Scoped to team for security
    data: { revokedAt: new Date() },
  });
}

// Used in API middleware to authenticate requests
export async function validateApiKey(rawKey: string) {
  if (!rawKey.startsWith(KEY_PREFIX)) return null;

  const hash = createHash("sha256").update(rawKey).digest("hex");
  const record = await db.apiKey.findFirst({
    where: { keyHash: hash, revokedAt: null },
    include: { team: true },
  });

  if (record) {
    // Update last used timestamp (non-blocking)
    db.apiKey.update({
      where: { id: record.id },
      data: { lastUsedAt: new Date() },
    }).catch(console.error);
  }

  return record;
}
// components/portal/ApiKeyManager.tsx
"use client";

import { useState, useTransition } from "react";
import { createApiKeyAction, revokeApiKeyAction } from "@/app/actions/api-keys";
import { Copy, Trash2, Eye, EyeOff, Plus } from "lucide-react";

export function ApiKeyManager({ initialKeys }: { initialKeys: any[] }) {
  const [keys, setKeys] = useState(initialKeys);
  const [newKey, setNewKey] = useState<string | null>(null); // Show once after creation
  const [label, setLabel] = useState("");
  const [isPending, startTransition] = useTransition();
  const [copied, setCopied] = useState(false);

  const handleCreate = () => {
    if (!label.trim()) return;
    startTransition(async () => {
      const result = await createApiKeyAction(label);
      if (result.rawKey) {
        setNewKey(result.rawKey);
        setKeys((prev) => [result.key, ...prev]);
        setLabel("");
      }
    });
  };

  const handleRevoke = (keyId: string) => {
    startTransition(async () => {
      await revokeApiKeyAction(keyId);
      setKeys((prev) => prev.filter((k) => k.id !== keyId));
    });
  };

  const handleCopy = async () => {
    if (!newKey) return;
    await navigator.clipboard.writeText(newKey);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="space-y-6">
      <div>
        <h3 className="text-sm font-semibold text-gray-900 mb-3">API Keys</h3>

        {/* New key reveal banner */}
        {newKey && (
          <div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
            <p className="text-sm font-medium text-green-800 mb-2">
              API key created โ€” copy it now. You won't be able to see it again.
            </p>
            <div className="flex items-center gap-2">
              <code className="flex-1 text-sm font-mono bg-white border border-green-200 rounded px-3 py-1.5 text-green-900 truncate">
                {newKey}
              </code>
              <button
                onClick={handleCopy}
                className="px-3 py-1.5 bg-green-600 text-white rounded text-xs font-medium"
              >
                {copied ? "Copied!" : <Copy className="h-4 w-4" />}
              </button>
            </div>
            <button
              onClick={() => setNewKey(null)}
              className="mt-2 text-xs text-green-600 underline"
            >
              I've copied the key
            </button>
          </div>
        )}

        {/* Create new key */}
        <div className="flex gap-2 mb-4">
          <input
            value={label}
            onChange={(e) => setLabel(e.target.value)}
            placeholder="Key label (e.g. Production, CI/CD)"
            className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
            onKeyDown={(e) => e.key === "Enter" && handleCreate()}
          />
          <button
            onClick={handleCreate}
            disabled={!label.trim() || isPending}
            className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            <Plus className="h-4 w-4" />
            Generate key
          </button>
        </div>

        {/* Key list */}
        <div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
          {keys.length === 0 && (
            <p className="px-4 py-6 text-sm text-gray-400 text-center">
              No API keys. Generate one above.
            </p>
          )}
          {keys.map((key) => (
            <div key={key.id} className="flex items-center gap-3 px-4 py-3">
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium text-gray-900">{key.label}</p>
                <p className="text-xs text-gray-400 font-mono mt-0.5">
                  {key.keyPrefix}โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข
                </p>
              </div>
              <div className="text-right text-xs text-gray-400">
                <p>Created by {key.createdBy?.name}</p>
                {key.lastUsedAt && (
                  <p>Last used {new Date(key.lastUsedAt).toLocaleDateString()}</p>
                )}
              </div>
              <button
                onClick={() => {
                  if (confirm(`Revoke key "${key.label}"? This cannot be undone.`)) {
                    handleRevoke(key.id);
                  }
                }}
                className="p-1.5 text-gray-400 hover:text-red-500 rounded hover:bg-red-50"
                title="Revoke key"
              >
                <Trash2 className="h-4 w-4" />
              </button>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

Portal Navigation Layout

// app/settings/layout.tsx
import { getCurrentUser } from "@/lib/auth";
import { getPermissionContext } from "@/lib/auth/permission-context";
import { redirect } from "next/navigation";
import { SettingsNav } from "@/components/portal/SettingsNav";

const NAV_ITEMS = [
  { label: "Billing",   href: "/settings/billing",  requiredAction: "read", requiredSubject: "billing" },
  { label: "Usage",     href: "/settings/usage",    requiredAction: "read", requiredSubject: "billing" },
  { label: "Team",      href: "/settings/team",     requiredAction: "read", requiredSubject: "member" },
  { label: "API Keys",  href: "/settings/api-keys", requiredAction: "read", requiredSubject: "api_key" },
  { label: "Profile",   href: "/settings/profile",  requiredAction: "read", requiredSubject: "workspace" },
] as const;

export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
  const ctx = await getPermissionContext();
  if (!ctx) redirect("/login");

  const visibleNav = NAV_ITEMS.filter((item) =>
    ctx.can(item.requiredAction as any, item.requiredSubject as any)
  );

  return (
    <div className="flex gap-8 max-w-5xl mx-auto py-8 px-4">
      <aside className="w-48 flex-shrink-0">
        <nav className="space-y-1">
          {visibleNav.map((item) => (
            <SettingsNavLink key={item.href} href={item.href} label={item.label} />
          ))}
        </nav>
      </aside>
      <main className="flex-1 min-w-0">{children}</main>
    </div>
  );
}

Cost and Timeline

ComponentTimelineCost (USD)
Billing tab (Stripe invoices)1โ€“2 days$800โ€“$1,600
Usage metering dashboard1โ€“2 days$800โ€“$1,600
API key generation + management1โ€“2 days$800โ€“$1,600
Team management tab1โ€“2 days$800โ€“$1,600
Portal navigation + layout0.5 day$300โ€“$500
Full customer portal2โ€“3 weeks$10,000โ€“$18,000

See Also


Working With Viprasol

We build customer portals for SaaS products โ€” from basic billing pages through full self-serve portals with usage dashboards, API key management, and team administration. Our team has shipped customer portals that reduced billing-related support tickets by 60โ€“80%.

What we deliver:

  • Stripe invoice history with PDF download and hosted invoice links
  • Real-time usage metering dashboard with limit warnings
  • Self-serve team management (invite, role change, remove)
  • API key generation with SHA-256 hashed storage
  • Permission-gated portal navigation

Explore our SaaS development services or contact us to build your customer portal.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.