A customer portal build is a deflection problem before it is a UI problem. The support inbox already tells us which workflows the customer is willing to do themselves; the portal is the place that lets them. Every screen has a one-line job: take this ticket type out of the inbox. The success metric is not the design of the screen, it is the percentage of tickets that stop arriving.
The five files below are the scaffolding the engagement leaves behind. The auth-sharing layout, the Stripe Customer Portal session creator, the team management endpoint with role guards, the usage chart, and the API key page.
1. The auth-sharing layout
The portal lives at /account/* inside the product app. The layout reads the existing session; an unauthenticated visit redirects to the product's sign-in flow. There is no separate auth surface to maintain.
// app/account/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth/session'
import { AccountNav } from '@/components/account/AccountNav'
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession()
if (!session) {
redirect('/auth/sign-in?next=/account')
}
return (
<div className="ds-account-shell">
<AccountNav user={session.user} />
<main className="ds-account-content">{children}</main>
</div>
)
}
2. The Stripe Customer Portal session creator
The endpoint creates a portal session and returns a URL the client opens. The return URL lands the customer back inside the product. The portal configuration ID is pinned per environment so a staging change cannot leak into production.
// app/api/billing/portal-session/route.ts
import Stripe from 'stripe'
import { getServerSession } from '@/lib/auth/session'
import { getCustomer } from '@/lib/billing/customer'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const customer = await getCustomer(session.user.id)
if (!customer?.stripeCustomerId) {
return Response.json({ success: false, error: 'no_customer' }, { status: 400 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.PUBLIC_APP_URL}/account/billing`,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
})
return Response.json({ success: true, data: { url: portal.url } })
}
3. The team management endpoint with role guards
Team writes go through one endpoint that reads the actor's role, validates the action against a permission matrix, and audits the change. The matrix lives in a config file the team can read; the endpoint is small.
// app/api/team/members/route.ts
import { getServerSession } from '@/lib/auth/session'
import { adminDb } from '@/lib/admin/db'
import { canPerform } from '@/lib/permissions/can'
interface ChangeRolePayload {
memberId: string
newRole: 'admin' | 'member' | 'viewer'
}
export async function PATCH(req: Request): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const body = (await req.json()) as ChangeRolePayload
const actor = await loadMember(session.user.id)
if (!canPerform(actor, 'team.change_role', { targetMemberId: body.memberId })) {
return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
}
const db = await adminDb()
const before = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('team_members').update({ role: body.newRole }).eq('id', body.memberId)
const after = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('portal_audit').insert({
actor_user_id: session.user.id,
entity_type: 'team_member',
entity_id: body.memberId,
action: 'change_role',
before: before.data,
after: after.data,
})
await notifyTeam({ event: 'role_changed', memberId: body.memberId, newRole: body.newRole })
return Response.json({ success: true, data: after.data })
}
async function loadMember(userId: string) { /* ... */ return { id: userId, role: 'admin' as const } }
async function notifyTeam(_input: { event: string; memberId: string; newRole: string }) { /* ... */ }
4. The usage chart
The chart component reads from the same metering source the finance team reads. The customer's number is the finance team's number; the portal does not have its own copy that can drift. Two ranges (last 30 days, last 12 months) and a CSV export.
// app/account/usage/UsageChart.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@/components/charts/LineChart'
interface UsageRange {
date: string
count: number
}
interface UsageResponse {
success: boolean
data: { last30: UsageRange[]; last12Months: UsageRange[]; forecastNextMonth: number }
}
export function UsageChart() {
const { data, isLoading } = useQuery<UsageResponse>({
queryKey: ['account', 'usage'],
queryFn: async () => (await fetch('/api/account/usage')).json() as Promise<UsageResponse>,
staleTime: 5 * 60_000,
})
if (isLoading || !data?.success) return <p>Loading usage…</p>
return (
<section className="ds-stack ds-stack--md">
<h2 className="ds-heading-ui">Last 30 days</h2>
<LineChart series={data.data.last30} xKey="date" yKey="count" />
<h2 className="ds-heading-ui">Last 12 months</h2>
<LineChart series={data.data.last12Months} xKey="date" yKey="count" />
<p className="ds-text-sm ds-text-secondary">
Forecast next month: {data.data.forecastNextMonth.toLocaleString()} units.
</p>
<a href="/api/account/usage/export.csv" className="ds-btn ds-btn--ghost">Download CSV</a>
</section>
)
}
5. The API key page
The customer creates, names, scopes and revokes keys without engineering involvement. Keys show only once on creation; we store a hash. Rotation is one click, which creates a new key and grace-period-expires the old one.
// app/account/api-keys/page.tsx
import { adminDb } from '@/lib/admin/db'
import { getServerSession } from '@/lib/auth/session'
import { CreateKeyForm } from './CreateKeyForm'
import { KeyList } from './KeyList'
export default async function ApiKeysPage() {
const session = await getServerSession()
if (!session) return null
const db = await adminDb()
const { data: keys } = await db
.from('api_keys')
.select('id, name, scope, last_used_at, created_at, revoked_at')
.eq('owner_user_id', session.user.id)
.order('created_at', { ascending: false })
return (
<section className="ds-stack ds-stack--lg">
<header className="ds-stack ds-stack--sm">
<h1 className="ds-editorial-title">API keys</h1>
<p className="ds-editorial-lede">Create scoped keys for the integrations that use this account.</p>
</header>
<CreateKeyForm />
<KeyList keys={keys ?? []} />
</section>
)
}
6. What this composes
The shared layout keeps the customer inside one session. The portal session creator hands billing off to Stripe without leaving the product. The team endpoint moves role changes from the support inbox to a button. The usage chart shows the customer the same number finance sees. The API key page closes the engineer-time-waster ticket type.
The support inbox breathes again. The Monday morning queue is the genuinely human cases the team wanted to be working on. The repeat questions stop arriving because the answer is one click away. The team that grew to handle ticket volume gets its time back to ship the product the portal is now part of.