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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Billing tab (Stripe invoices) | 1โ2 days | $800โ$1,600 |
| Usage metering dashboard | 1โ2 days | $800โ$1,600 |
| API key generation + management | 1โ2 days | $800โ$1,600 |
| Team management tab | 1โ2 days | $800โ$1,600 |
| Portal navigation + layout | 0.5 day | $300โ$500 |
| Full customer portal | 2โ3 weeks | $10,000โ$18,000 |
See Also
- SaaS Subscription Upgrade Downgrade โ Plan change flows in billing tab
- SaaS Role-Based Access โ Permissions for portal sections
- SaaS Team Invitations โ Team management tab invitation flow
- Stripe Webhook Handling โ Keeping invoice state in sync
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.