A self-service portal that retires the top five support tickets in week one
One Next.js portal at `app.yourdomain.com/account`, gated by the product's existing auth, with the five sections every SaaS customer actually wants: account, team, billing, usage, invoices. Stripe Customer Portal handles cards. Usage reads from the metering source of truth. Team management does role changes without a ticket. The repeat questions stop reaching the inbox.
The problem
The same five questions are 60% of your support volume
Pull a month of support tickets and tag them. The shape is consistent across SaaS: 'change the card on file', 'update the company name on the invoice', 'add a teammate', 'why was I charged this much', 'download last quarter's invoices'. Five questions, 50 to 60% of the inbox, every one of them a workflow Stripe and the product already support but which the customer cannot reach without a human. The support engineer spends Monday on refunds, Tuesday on seat changes, Wednesday writing the same email seven times. We build the portal that handles those five workflows. The customer self-serves. The support inbox handles the genuinely human cases. The team that grew to keep up with tickets gets its time back.
Our approach
Six steps from "five questions every day" to a portal customers actually use
Ticket audit before scope. Scope before auth. Auth before screens. Screens before Stripe wiring. Stripe wiring before usage views. Usage views before notifications. Each step depends on the one before; skipping produces a portal that looks complete and answers none of the questions.
- 01
Audit the support inbox
We pull the last 90 days of tickets, tag them, and produce a ranked list of the top 15 reasons customers contact support. The output is a CSV the founder reads in one sitting; the top five usually cover 50-60% of volume, the top fifteen cover 80%. Each ticket type gets a column for current resolution time, current human cost, and whether a portal screen could absorb it. The audit is the brief for everything that follows.
- 02
Define the portal scope
From the audit, we pick the workflows the portal will own in the first cutover (typically: account, team, billing, usage, invoices) and the workflows that explicitly stay with support (typically: refunds beyond 30 days, custom contracts, integrations help). The list lives in a document the founder signs off; we do not build screens for workflows that did not make the list.
- 03
Wire the portal to the product auth
The portal is the same Next.js app or a sibling app that shares the auth session. No second login. The customer clicks 'Account' in the product and lands inside the portal. We do not build a parallel auth flow because every parallel auth flow grows its own bugs.
- 04
Build the five screens
Account (name, email, password reset, two-factor). Team (invite, remove, role change, with optimistic UI). Billing (card on file via Stripe Customer Portal, billing email, company details, VAT). Usage (the metric the customer pays for, with last 30 days and last 12 months charts, sourced from the same metering data finance reads). Invoices (list, download PDF, mark as paid for offline payments). Each screen ships behind a feature flag so the rollout is incremental.
- 05
Stripe Customer Portal integration
We embed Stripe Customer Portal for the card management and invoice list (Stripe maintains both better than we could). The portal session is created from the product backend with a return URL that lands the customer back inside the product, not on a Stripe page. The flow looks native; the customer never realises Stripe is rendering the form.
- 06
Notification and audit
Every portal action that changes something the support team would care about (role change, billing email change, plan switch) fires an event the team can subscribe to. Slack gets a notification, the audit log captures actor and timestamp, and the customer gets an email so a change made by a teammate is not a surprise. The portal stops being a black box.
What we deliver
Support inbox audit
A tagged CSV of 90 days of support tickets, ranked by frequency, with a column flagging which ones a portal screen could absorb. The artefact the founder uses to decide scope. Re-runnable each quarter to keep the portal honest.
Portal scope document
A one-page document that lists every portal screen in scope and every workflow that stays with support. Signed before code starts; pinned in the repo so future scope creep has to argue against an existing decision.
Account screen
Name, email, password reset, two-factor enrolment, sessions list with revoke. Replaces the four most common account-related tickets. The account section every SaaS user expects to find when they click their avatar.
Team management
Invite by email, remove member, change role, transfer ownership. Role definitions live in the same permission config the product uses. Invite email links into a typed flow that lands the new member inside the product on day one.
Billing section
Stripe Customer Portal embedded for cards, billing email and company name editable in-product, VAT and tax ID with country-specific validation, billing history with downloadable PDFs. The page the customer hits when the finance team asks for an invoice.
Usage view
A chart of the metric the customer pays for (API calls, seats, GB, executions) across the last 30 days and last 12 months. Sourced from the metering pipeline that finance trusts. Includes a downloadable CSV for the customer's finance team and a forecast for next month based on the current run rate.
API keys page
A page where customers create, name, scope and revoke API keys without a ticket. Keys show only once on creation; rotation is one click. Replaces the most common engineer-time-waster ticket in technical SaaS.
Stripe Customer Portal config
A Stripe Customer Portal configuration tuned to the product (which features show, which pages are reachable, which billing email is editable). Branded with the product logo. Returns the customer to the in-product billing page, not a Stripe page.
Webhook and event handlers
Handlers for every portal-triggered Stripe event (`customer.updated`, `payment_method.attached`, `customer_subscription.updated`) that keep the product copy of customer state in sync. Idempotent; replayable.
Notification system
Slack, email, and in-app notifications for the portal actions the team cares about (role change, billing change, plan switch, API key created). Subscribable per channel; defaults set to what an early-stage support team needs.
Portal audit log
Every portal write lands in a `portal_audit` table with actor, entity, before/after. Surfaced in the same admin audit log view used for staff actions; the support team sees the customer's full activity timeline when they open a ticket.
Embedded help and feedback
A contextual help drawer with three to five short articles per section and a feedback button that opens a typed form. The drawer reads from a CMS; new help articles ship without code. The feedback funnels into a Linear board the product team reviews weekly.
Five files that compose a customer portal that retires support tickets
The five files below compose the portal: the auth-sharing layout that keeps the customer inside one session, the Stripe Customer Portal session creator, the team management endpoint with role guards, the usage chart that reads from the metering data, and the API key page with scoped tokens.
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.
Frequently asked questions
Can we do this without integrating Stripe Customer Portal?
Yes, and sometimes we do. The Customer Portal is the fastest path to a billing surface that Stripe keeps up to date for free; for most early-stage SaaS it is the right answer. Teams with custom billing logic (usage-based with annual commitments, contracts negotiated per customer) usually build the billing screens directly against the Stripe API instead. We do the heavier path when the audit shows it is warranted.
How do you keep the portal from becoming another product surface that demands engineering time forever?
Two patterns. First, the portal uses the same design system as the product; new screens drop into existing components instead of inventing layout. Second, the scope document is the spec; we do not add screens without an updated scope, and the quarterly support audit drives what comes in and out. Most portals stabilise after the first six months.
What about customers on a custom contract who do not have a Stripe subscription?
They get a read-only billing section with their contract terms, the renewal date, and the invoice history. The change buttons that would normally let them swap plans are hidden. We model this case explicitly because every enterprise SaaS has it; the portal handles both worlds without confusing the customer.
How does the portal interact with our existing in-app settings page?
It often replaces the in-app settings page; sometimes it lives alongside. The scoping call decides. The pattern we see most often: the legacy settings page becomes a redirect to the portal once the new flows ship; the URL `app.x/settings` keeps working so existing email links do not 404.
How long does it take?
Six to eight weeks from kickoff. Inbox audit takes one week. Scope and auth take one week. The five screens take three to four weeks combined. Stripe Customer Portal embedding takes half a week. Notifications, audit log, and embedded help take the last week. Buffer for one round of customer feedback before the cutover.
Do customers actually use the portal, or do they keep emailing support?
The five workflows the portal handles drop by 60 to 80% in the support inbox within the first month of cutover. The remaining 20 to 40% are usually customers who emailed before discovering the portal exists; an in-product nudge and a one-time email closes most of that gap. We measure the deflection rate quarterly and surface it on the dashboard.
What about non-English customers?
The portal supports the same locales the product does. If the product is in English only, we build the portal in English only and flag the locales the audit suggests adding next. We do not build a portal in three languages when the product is in one; the work is wasted until the product catches up.
Do you replace our support engineer?
No. The portal makes the support engineer's work scale. Instead of seven copies of the same email per week, the engineer handles the genuinely complex tickets the portal cannot. Support engineers usually find the portal freeing because the routine work compresses; the work that remains is the work the engineer wanted to do in the first place.
Scope your customer portal
A scoping call, an audit of the support inbox in week one, a fixed scope and a number we hold. Six to eight weeks from kickoff to a portal customers use and a support inbox that breathes again.