Skip to main content
RapidDev - Software Development Agency

How to Build a Customer Portal with V0

Build a self-service customer portal where users view their subscription status, download invoices, submit support tickets, and manage their account without contacting support. V0 generates the Next.js dashboard and ticket system while Stripe webhooks sync invoice data to Supabase automatically using ON CONFLICT upserts.

What you'll build

  • Customer dashboard with subscription summary, open tickets, and announcements
  • Support ticket system with threaded messages and status tracking
  • Invoice history with PDF download links synced from Stripe via webhooks
  • Stripe Customer Portal integration for subscription management
  • Account settings page with profile editing and company information
  • Announcements banner for system-wide notifications
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursV0 Premium or Free tier, Supabase free tier, Stripe accountLast updated April 2026RapidDev Engineering Team
TL;DR

Build a self-service customer portal where users view their subscription status, download invoices, submit support tickets, and manage their account without contacting support. V0 generates the Next.js dashboard and ticket system while Stripe webhooks sync invoice data to Supabase automatically using ON CONFLICT upserts.

Quick facts about this guide
FactValue
ToolV0
Build time1-2 hours
DifficultyIntermediate
Last updatedApril 2026

Project overview

Tech stack

V0 by Vercel (AI code generation)
Next.js 14+ (App Router)
TypeScript
Tailwind CSS + shadcn/ui
Supabase (PostgreSQL + Auth)
Stripe (Billing + Customer Portal + Webhooks)

Prerequisites

  • A V0 account (free or Premium)
  • A Supabase project with Auth configured
  • A Stripe account with Billing and Customer Portal enabled
  • Stripe Customer Portal configured in the Stripe Dashboard (products, cancellation policy, update payment method)
  • Basic familiarity with React Server Components and TypeScript

Build steps

1

Set Up the Supabase Schema for Customers and Tickets

Create tables for customers, support tickets with threaded messages, invoices synced from Stripe, and announcements. The invoices table has a unique constraint on stripe_invoice_id for safe upserts from webhooks.

typescript
1-- Customers linked to Stripe
2create table customers (
3 id uuid primary key default gen_random_uuid(),
4 user_id uuid references auth.users unique not null,
5 company_name text,
6 plan text default 'free',
7 stripe_customer_id text unique,
8 created_at timestamptz default now()
9);
10
11-- Support tickets
12create table support_tickets (
13 id uuid primary key default gen_random_uuid(),
14 customer_id uuid references customers not null,
15 subject text not null,
16 description text not null,
17 status text check (status in ('open','in_progress','resolved','closed')) default 'open',
18 priority text check (priority in ('low','medium','high','urgent')) default 'medium',
19 created_at timestamptz default now(),
20 updated_at timestamptz default now()
21);
22
23-- Ticket messages (threaded conversation)
24create table ticket_messages (
25 id uuid primary key default gen_random_uuid(),
26 ticket_id uuid references support_tickets on delete cascade not null,
27 sender_id uuid references auth.users not null,
28 sender_type text check (sender_type in ('customer','agent')) not null,
29 content text not null,
30 attachments text[] default '{}',
31 created_at timestamptz default now()
32);
33
34-- Invoices synced from Stripe
35create table invoices (
36 id uuid primary key default gen_random_uuid(),
37 customer_id uuid references customers not null,
38 stripe_invoice_id text unique not null,
39 amount numeric not null,
40 status text not null,
41 pdf_url text,
42 period_start date,
43 period_end date,
44 created_at timestamptz default now()
45);
46
47-- System announcements
48create table announcements (
49 id uuid primary key default gen_random_uuid(),
50 title text not null,
51 content text not null,
52 is_active boolean default true,
53 created_at timestamptz default now()
54);
55
56-- RLS
57alter table customers enable row level security;
58alter table support_tickets enable row level security;
59alter table ticket_messages enable row level security;
60alter table invoices enable row level security;
61alter table announcements enable row level security;
62
63create policy "Users read own customer" on customers for select using (auth.uid() = user_id);
64create policy "Users read own tickets" on support_tickets for select
65 using (customer_id in (select id from customers where user_id = auth.uid()));
66create policy "Users create tickets" on support_tickets for insert
67 with check (customer_id in (select id from customers where user_id = auth.uid()));
68create policy "Users read own messages" on ticket_messages for select
69 using (ticket_id in (select id from support_tickets where customer_id in (select id from customers where user_id = auth.uid())));
70create policy "Users create messages" on ticket_messages for insert
71 with check (ticket_id in (select id from support_tickets where customer_id in (select id from customers where user_id = auth.uid())));
72create policy "Users read own invoices" on invoices for select
73 using (customer_id in (select id from customers where user_id = auth.uid()));
74create policy "Anyone reads active announcements" on announcements for select using (is_active = true);

Expected result: Database tables created with RLS policies ensuring customers only see their own data. The invoices table has a unique constraint on stripe_invoice_id for safe webhook upserts.

2

Build the Portal Dashboard with Subscription Summary

Create the portal dashboard as a Server Component that fetches the customer's subscription plan, open ticket count, latest invoices, and active announcements. Use shadcn/ui Card for each dashboard widget and Alert for announcements.

typescript
1// app/portal/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { redirect } from 'next/navigation';
4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5import { Badge } from '@/components/ui/badge';
6import { Button } from '@/components/ui/button';
7import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
8import Link from 'next/link';
9import { CreditCard, Headphones, FileText, Bell } from 'lucide-react';
10
11export default async function PortalDashboard() {
12 const supabase = await createClient();
13 const { data: { user } } = await supabase.auth.getUser();
14 if (!user) redirect('/login');
15
16 const { data: customer } = await supabase
17 .from('customers')
18 .select('*')
19 .eq('user_id', user.id)
20 .single();
21
22 if (!customer) redirect('/onboarding');
23
24 // Fetch dashboard data in parallel
25 const [ticketRes, invoiceRes, announcementRes] = await Promise.all([
26 supabase
27 .from('support_tickets')
28 .select('id', { count: 'exact' })
29 .eq('customer_id', customer.id)
30 .in('status', ['open', 'in_progress']),
31 supabase
32 .from('invoices')
33 .select('*')
34 .eq('customer_id', customer.id)
35 .order('created_at', { ascending: false })
36 .limit(3),
37 supabase
38 .from('announcements')
39 .select('*')
40 .eq('is_active', true)
41 .order('created_at', { ascending: false })
42 .limit(1),
43 ]);
44
45 const openTickets = ticketRes.count ?? 0;
46 const recentInvoices = invoiceRes.data ?? [];
47 const announcement = announcementRes.data?.[0];
48
49 return (
50 <div className="max-w-5xl mx-auto p-6 space-y-6">
51 <h1 className="text-3xl font-bold">Welcome back</h1>
52
53 {announcement && (
54 <Alert>
55 <Bell className="h-4 w-4" />
56 <AlertTitle>{announcement.title}</AlertTitle>
57 <AlertDescription>{announcement.content}</AlertDescription>
58 </Alert>
59 )}
60
61 <div className="grid md:grid-cols-3 gap-4">
62 <Card>
63 <CardHeader className="flex flex-row items-center gap-2 pb-2">
64 <CreditCard className="h-5 w-5 text-primary" />
65 <CardTitle className="text-sm text-muted-foreground">Current Plan</CardTitle>
66 </CardHeader>
67 <CardContent>
68 <p className="text-2xl font-bold capitalize">{customer.plan}</p>
69 <Button variant="outline" size="sm" className="mt-2" asChild>
70 <Link href="/portal/billing">Manage Subscription</Link>
71 </Button>
72 </CardContent>
73 </Card>
74
75 <Card>
76 <CardHeader className="flex flex-row items-center gap-2 pb-2">
77 <Headphones className="h-5 w-5 text-primary" />
78 <CardTitle className="text-sm text-muted-foreground">Open Tickets</CardTitle>
79 </CardHeader>
80 <CardContent>
81 <p className="text-2xl font-bold">{openTickets}</p>
82 <Button variant="outline" size="sm" className="mt-2" asChild>
83 <Link href="/portal/tickets">View Tickets</Link>
84 </Button>
85 </CardContent>
86 </Card>
87
88 <Card>
89 <CardHeader className="flex flex-row items-center gap-2 pb-2">
90 <FileText className="h-5 w-5 text-primary" />
91 <CardTitle className="text-sm text-muted-foreground">Recent Invoices</CardTitle>
92 </CardHeader>
93 <CardContent>
94 <p className="text-2xl font-bold">{recentInvoices.length}</p>
95 <Button variant="outline" size="sm" className="mt-2" asChild>
96 <Link href="/portal/billing">View All</Link>
97 </Button>
98 </CardContent>
99 </Card>
100 </div>
101
102 {recentInvoices.length > 0 && (
103 <Card>
104 <CardHeader><CardTitle>Latest Invoices</CardTitle></CardHeader>
105 <CardContent>
106 <div className="space-y-2">
107 {recentInvoices.map((invoice) => (
108 <div key={invoice.id} className="flex items-center justify-between py-2 border-b last:border-0">
109 <div>
110 <span className="font-medium">${Number(invoice.amount / 100).toFixed(2)}</span>
111 <span className="text-sm text-muted-foreground ml-2">
112 {new Date(invoice.created_at).toLocaleDateString()}
113 </span>
114 </div>
115 <div className="flex items-center gap-2">
116 <Badge variant={invoice.status === 'paid' ? 'default' : 'secondary'}>
117 {invoice.status}
118 </Badge>
119 {invoice.pdf_url && (
120 <Button variant="ghost" size="sm" asChild>
121 <a href={invoice.pdf_url} target="_blank" rel="noopener noreferrer">
122 <FileText className="h-4 w-4" />
123 </a>
124 </Button>
125 )}
126 </div>
127 </div>
128 ))}
129 </div>
130 </CardContent>
131 </Card>
132 )}
133 </div>
134 );
135}

Expected result: A dashboard showing the customer's plan, open ticket count, and recent invoices with PDF download links. Active announcements appear as an alert banner at the top.

3

Create the Support Ticket System with Threaded Messages

Build the ticket list page, ticket creation form, and ticket detail page with a threaded message history. Use Server Actions for creating tickets and sending messages. ScrollArea provides a smooth scrollable container for long conversations.

typescript
1// app/portal/tickets/[id]/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { redirect } from 'next/navigation';
4import { Badge } from '@/components/ui/badge';
5import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6import { ScrollArea } from '@/components/ui/scroll-area';
7import { SendMessageForm } from './send-message-form';
8
9const statusColors: Record<string, string> = {
10 open: 'bg-blue-100 text-blue-800',
11 in_progress: 'bg-yellow-100 text-yellow-800',
12 resolved: 'bg-green-100 text-green-800',
13 closed: 'bg-gray-100 text-gray-800',
14};
15
16const priorityColors: Record<string, string> = {
17 low: 'bg-gray-100 text-gray-800',
18 medium: 'bg-blue-100 text-blue-800',
19 high: 'bg-orange-100 text-orange-800',
20 urgent: 'bg-red-100 text-red-800',
21};
22
23export default async function TicketDetailPage({
24 params,
25}: {
26 params: Promise<{ id: string }>;
27}) {
28 const { id } = await params;
29 const supabase = await createClient();
30 const { data: { user } } = await supabase.auth.getUser();
31 if (!user) redirect('/login');
32
33 const { data: ticket } = await supabase
34 .from('support_tickets')
35 .select('*')
36 .eq('id', id)
37 .single();
38
39 if (!ticket) return <div className="p-12 text-center">Ticket not found</div>;
40
41 const { data: messages } = await supabase
42 .from('ticket_messages')
43 .select('*')
44 .eq('ticket_id', id)
45 .order('created_at');
46
47 const allMessages = messages ?? [];
48
49 return (
50 <div className="max-w-3xl mx-auto p-6 space-y-4">
51 <div>
52 <div className="flex items-center gap-2 mb-1">
53 <h1 className="text-xl font-bold">{ticket.subject}</h1>
54 <Badge className={statusColors[ticket.status]}>{ticket.status}</Badge>
55 <Badge className={priorityColors[ticket.priority]}>{ticket.priority}</Badge>
56 </div>
57 <p className="text-sm text-muted-foreground">
58 Opened {new Date(ticket.created_at).toLocaleDateString()}
59 </p>
60 </div>
61
62 <Card>
63 <CardContent className="p-0">
64 <ScrollArea className="h-[400px] p-4">
65 <div className="space-y-4">
66 {/* Original description */}
67 <div className="p-3 rounded-lg bg-muted">
68 <p className="text-xs text-muted-foreground mb-1">You</p>
69 <p className="text-sm whitespace-pre-wrap">{ticket.description}</p>
70 <p className="text-xs text-muted-foreground mt-1">
71 {new Date(ticket.created_at).toLocaleString()}
72 </p>
73 </div>
74
75 {/* Thread messages */}
76 {allMessages.map((msg) => (
77 <div
78 key={msg.id}
79 className={`p-3 rounded-lg ${
80 msg.sender_type === 'customer'
81 ? 'bg-muted ml-4'
82 : 'bg-primary/10 mr-4'
83 }`}
84 >
85 <p className="text-xs text-muted-foreground mb-1">
86 {msg.sender_type === 'customer' ? 'You' : 'Support'}
87 </p>
88 <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
89 <p className="text-xs text-muted-foreground mt-1">
90 {new Date(msg.created_at).toLocaleString()}
91 </p>
92 </div>
93 ))}
94 </div>
95 </ScrollArea>
96 </CardContent>
97 </Card>
98
99 {ticket.status !== 'closed' && (
100 <SendMessageForm ticketId={id} />
101 )}
102 </div>
103 );
104}
105
106// app/portal/tickets/[id]/send-message-form.tsx
107'use client';
108
109import { useState } from 'react';
110import { Button } from '@/components/ui/button';
111import { Textarea } from '@/components/ui/textarea';
112import { sendTicketMessage } from '@/app/actions/tickets';
113import { Send } from 'lucide-react';
114
115export function SendMessageForm({ ticketId }: { ticketId: string }) {
116 const [content, setContent] = useState('');
117 const [sending, setSending] = useState(false);
118
119 async function handleSend() {
120 if (!content.trim()) return;
121 setSending(true);
122 await sendTicketMessage(ticketId, content);
123 setContent('');
124 setSending(false);
125 }
126
127 return (
128 <div className="flex gap-2">
129 <Textarea
130 value={content}
131 onChange={e => setContent(e.target.value)}
132 placeholder="Type your message..."
133 className="min-h-[60px]"
134 />
135 <Button onClick={handleSend} disabled={sending || !content.trim()}>
136 <Send className="h-4 w-4" />
137 </Button>
138 </div>
139 );
140}

Expected result: A ticket detail page with a scrollable message thread showing customer and agent messages. Customers can send new messages while the ticket is open.

4

Sync Stripe Invoices via Webhooks

Create a Stripe webhook handler that listens for invoice.created and invoice.paid events, upserts invoice data into Supabase using the stripe_invoice_id unique constraint, and captures the PDF URL for customer downloads.

typescript
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import Stripe from 'stripe';
4import { createClient } from '@supabase/supabase-js';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7
8export async function POST(request: NextRequest) {
9 const body = await request.text();
10 const sig = request.headers.get('stripe-signature')!;
11
12 let event: Stripe.Event;
13 try {
14 event = stripe.webhooks.constructEvent(
15 body, sig, process.env.STRIPE_WEBHOOK_SECRET!
16 );
17 } catch (err) {
18 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
19 }
20
21 const supabase = createClient(
22 process.env.NEXT_PUBLIC_SUPABASE_URL!,
23 process.env.SUPABASE_SERVICE_ROLE_KEY!
24 );
25
26 if (event.type === 'invoice.created' || event.type === 'invoice.paid') {
27 const invoice = event.data.object as Stripe.Invoice;
28
29 // Find customer by Stripe customer ID
30 const { data: customer } = await supabase
31 .from('customers')
32 .select('id')
33 .eq('stripe_customer_id', invoice.customer as string)
34 .single();
35
36 if (customer) {
37 // Upsert invoice using stripe_invoice_id unique constraint
38 await supabase.from('invoices').upsert({
39 customer_id: customer.id,
40 stripe_invoice_id: invoice.id,
41 amount: invoice.amount_due,
42 status: invoice.status ?? 'draft',
43 pdf_url: invoice.invoice_pdf ?? null,
44 period_start: invoice.period_start
45 ? new Date(invoice.period_start * 1000).toISOString().split('T')[0]
46 : null,
47 period_end: invoice.period_end
48 ? new Date(invoice.period_end * 1000).toISOString().split('T')[0]
49 : null,
50 }, { onConflict: 'stripe_invoice_id' });
51 }
52 }
53
54 if (event.type === 'customer.subscription.updated') {
55 const subscription = event.data.object as Stripe.Subscription;
56 const planName = subscription.items.data[0]?.price?.lookup_key ?? 'unknown';
57
58 await supabase
59 .from('customers')
60 .update({ plan: planName })
61 .eq('stripe_customer_id', subscription.customer as string);
62 }
63
64 return NextResponse.json({ received: true });
65}
66
67// app/api/stripe/portal/route.ts
68import { NextRequest, NextResponse } from 'next/server';
69import { createClient } from '@/lib/supabase/server';
70import Stripe from 'stripe';
71
72const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
73
74export async function POST(request: NextRequest) {
75 const supabase = await createClient();
76 const { data: { user } } = await supabase.auth.getUser();
77 if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
78
79 const { data: customer } = await supabase
80 .from('customers')
81 .select('stripe_customer_id')
82 .eq('user_id', user.id)
83 .single();
84
85 if (!customer?.stripe_customer_id) {
86 return NextResponse.json({ error: 'No Stripe customer' }, { status: 400 });
87 }
88
89 const session = await stripe.billingPortal.sessions.create({
90 customer: customer.stripe_customer_id,
91 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/portal/billing`,
92 });
93
94 return NextResponse.json({ url: session.url });
95}

Expected result: Stripe webhooks automatically sync invoices to Supabase. The Customer Portal route creates a Stripe billing session for subscription management.

5

Build the Billing Page and Connect Environment Variables

Create the billing page showing invoice history with PDF downloads and a Manage Subscription button that redirects to Stripe's Customer Portal. Use V0's Connect panel to provision Supabase and Stripe via Vercel Marketplace.

typescript
1// app/portal/billing/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { redirect } from 'next/navigation';
4import { Badge } from '@/components/ui/badge';
5import { Button } from '@/components/ui/button';
6import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
8import { ManageSubscriptionButton } from './manage-subscription-button';
9import { Download, CreditCard } from 'lucide-react';
10
11export default async function BillingPage() {
12 const supabase = await createClient();
13 const { data: { user } } = await supabase.auth.getUser();
14 if (!user) redirect('/login');
15
16 const { data: customer } = await supabase
17 .from('customers')
18 .select('*')
19 .eq('user_id', user.id)
20 .single();
21
22 if (!customer) redirect('/onboarding');
23
24 const { data: invoices } = await supabase
25 .from('invoices')
26 .select('*')
27 .eq('customer_id', customer.id)
28 .order('created_at', { ascending: false });
29
30 const allInvoices = invoices ?? [];
31
32 return (
33 <div className="max-w-4xl mx-auto p-6 space-y-6">
34 <h1 className="text-2xl font-bold">Billing</h1>
35
36 <Card>
37 <CardHeader className="flex flex-row items-center justify-between">
38 <div>
39 <CardTitle className="text-lg">Subscription</CardTitle>
40 <p className="text-sm text-muted-foreground mt-1">
41 Current plan: <Badge className="capitalize">{customer.plan}</Badge>
42 </p>
43 </div>
44 <ManageSubscriptionButton />
45 </CardHeader>
46 </Card>
47
48 <Card>
49 <CardHeader>
50 <CardTitle className="text-lg">Invoice History</CardTitle>
51 </CardHeader>
52 <CardContent>
53 <Table>
54 <TableHeader>
55 <TableRow>
56 <TableHead>Date</TableHead>
57 <TableHead>Period</TableHead>
58 <TableHead>Amount</TableHead>
59 <TableHead>Status</TableHead>
60 <TableHead></TableHead>
61 </TableRow>
62 </TableHeader>
63 <TableBody>
64 {allInvoices.map((invoice) => (
65 <TableRow key={invoice.id}>
66 <TableCell>
67 {new Date(invoice.created_at).toLocaleDateString()}
68 </TableCell>
69 <TableCell className="text-sm text-muted-foreground">
70 {invoice.period_start && invoice.period_end
71 ? `${new Date(invoice.period_start).toLocaleDateString()} - ${new Date(invoice.period_end).toLocaleDateString()}`
72 : '—'}
73 </TableCell>
74 <TableCell className="font-medium">
75 ${(Number(invoice.amount) / 100).toFixed(2)}
76 </TableCell>
77 <TableCell>
78 <Badge variant={invoice.status === 'paid' ? 'default' : 'secondary'}>
79 {invoice.status}
80 </Badge>
81 </TableCell>
82 <TableCell>
83 {invoice.pdf_url && (
84 <Button variant="ghost" size="sm" asChild>
85 <a href={invoice.pdf_url} target="_blank" rel="noopener noreferrer">
86 <Download className="h-4 w-4" />
87 </a>
88 </Button>
89 )}
90 </TableCell>
91 </TableRow>
92 ))}
93 </TableBody>
94 </Table>
95 </CardContent>
96 </Card>
97 </div>
98 );
99}
100
101// app/portal/billing/manage-subscription-button.tsx
102'use client';
103
104import { useState } from 'react';
105import { Button } from '@/components/ui/button';
106import { CreditCard } from 'lucide-react';
107
108export function ManageSubscriptionButton() {
109 const [loading, setLoading] = useState(false);
110
111 async function handleClick() {
112 setLoading(true);
113 const res = await fetch('/api/stripe/portal', { method: 'POST' });
114 const { url } = await res.json();
115 if (url) window.location.href = url;
116 setLoading(false);
117 }
118
119 return (
120 <Button onClick={handleClick} disabled={loading}>
121 <CreditCard className="h-4 w-4 mr-2" />
122 {loading ? 'Loading...' : 'Manage Subscription'}
123 </Button>
124 );
125}

Expected result: A billing page with invoice history table including PDF download links and a Manage Subscription button that redirects to Stripe's hosted Customer Portal.

Complete code

app/portal/page.tsx
1import { createClient } from '@/lib/supabase/server';
2import { redirect } from 'next/navigation';
3import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4import { Badge } from '@/components/ui/badge';
5import { Button } from '@/components/ui/button';
6import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
7import Link from 'next/link';
8import { CreditCard, Headphones, FileText, Bell } from 'lucide-react';
9
10export default async function PortalDashboard() {
11 const supabase = await createClient();
12 const { data: { user } } = await supabase.auth.getUser();
13 if (!user) redirect('/login');
14
15 const { data: customer } = await supabase.from('customers').select('*').eq('user_id', user.id).single();
16 if (!customer) redirect('/onboarding');
17
18 const [ticketRes, invoiceRes, announcementRes] = await Promise.all([
19 supabase.from('support_tickets').select('id', { count: 'exact' }).eq('customer_id', customer.id).in('status', ['open', 'in_progress']),
20 supabase.from('invoices').select('*').eq('customer_id', customer.id).order('created_at', { ascending: false }).limit(3),
21 supabase.from('announcements').select('*').eq('is_active', true).order('created_at', { ascending: false }).limit(1),
22 ]);
23
24 const openTickets = ticketRes.count ?? 0;
25 const recentInvoices = invoiceRes.data ?? [];
26 const announcement = announcementRes.data?.[0];
27
28 return (
29 <div className="max-w-5xl mx-auto p-6 space-y-6">
30 <h1 className="text-3xl font-bold">Welcome back</h1>
31
32 {announcement && (
33 <Alert>
34 <Bell className="h-4 w-4" />
35 <AlertTitle>{announcement.title}</AlertTitle>
36 <AlertDescription>{announcement.content}</AlertDescription>
37 </Alert>
38 )}
39
40 <div className="grid md:grid-cols-3 gap-4">
41 <Card>
42 <CardHeader className="flex flex-row items-center gap-2 pb-2">
43 <CreditCard className="h-5 w-5 text-primary" />
44 <CardTitle className="text-sm text-muted-foreground">Current Plan</CardTitle>
45 </CardHeader>
46 <CardContent>
47 <p className="text-2xl font-bold capitalize">{customer.plan}</p>
48 <Button variant="outline" size="sm" className="mt-2" asChild>
49 <Link href="/portal/billing">Manage</Link>
50 </Button>
51 </CardContent>
52 </Card>
53 <Card>
54 <CardHeader className="flex flex-row items-center gap-2 pb-2">
55 <Headphones className="h-5 w-5 text-primary" />
56 <CardTitle className="text-sm text-muted-foreground">Open Tickets</CardTitle>
57 </CardHeader>
58 <CardContent>
59 <p className="text-2xl font-bold">{openTickets}</p>
60 <Button variant="outline" size="sm" className="mt-2" asChild>
61 <Link href="/portal/tickets">View</Link>
62 </Button>
63 </CardContent>
64 </Card>
65 <Card>
66 <CardHeader className="flex flex-row items-center gap-2 pb-2">
67 <FileText className="h-5 w-5 text-primary" />
68 <CardTitle className="text-sm text-muted-foreground">Invoices</CardTitle>
69 </CardHeader>
70 <CardContent>
71 <p className="text-2xl font-bold">{recentInvoices.length}</p>
72 <Button variant="outline" size="sm" className="mt-2" asChild>
73 <Link href="/portal/billing">View All</Link>
74 </Button>
75 </CardContent>
76 </Card>
77 </div>
78
79 {recentInvoices.length > 0 && (
80 <Card>
81 <CardHeader><CardTitle>Latest Invoices</CardTitle></CardHeader>
82 <CardContent className="space-y-2">
83 {recentInvoices.map(inv => (
84 <div key={inv.id} className="flex items-center justify-between py-2 border-b last:border-0">
85 <div>
86 <span className="font-medium">${(Number(inv.amount) / 100).toFixed(2)}</span>
87 <span className="text-sm text-muted-foreground ml-2">{new Date(inv.created_at).toLocaleDateString()}</span>
88 </div>
89 <div className="flex items-center gap-2">
90 <Badge variant={inv.status === 'paid' ? 'default' : 'secondary'}>{inv.status}</Badge>
91 {inv.pdf_url && <Button variant="ghost" size="sm" asChild><a href={inv.pdf_url} target="_blank" rel="noopener noreferrer"><FileText className="h-4 w-4" /></a></Button>}
92 </div>
93 </div>
94 ))}
95 </CardContent>
96 </Card>
97 )}
98 </div>
99 );
100}

Customization ideas

Common pitfalls

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Best practices

  • Use Stripe's hosted Customer Portal for subscription management instead of building custom billing UI
  • Sync invoices via webhooks with ON CONFLICT upserts for idempotent processing
  • Always use request.text() for Stripe webhook body parsing to preserve raw body for signature verification
  • Use the service role key in webhook handlers since they run outside the user's auth context
  • Fetch dashboard data in parallel with Promise.all to minimize page load time
  • Set RLS policies so customers only see their own tickets, invoices, and account data
  • Use V0's Connect panel to provision Supabase and Stripe via Vercel Marketplace for automatic env var setup

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I need a Next.js App Router customer portal with: 1) Dashboard page showing subscription plan, open ticket count, and recent invoices 2) Support ticket system with threaded messages 3) Billing page with invoice table and Stripe Customer Portal redirect 4) Announcements banner. Use shadcn/ui Card, Table, Badge, ScrollArea, and Alert with TypeScript and Supabase.

Build Prompt

Create a Stripe webhook handler in Next.js App Router that listens for invoice.created, invoice.paid, and customer.subscription.updated events. Use request.text() for body parsing, verify the webhook signature, and upsert invoice data into Supabase using ON CONFLICT (stripe_invoice_id). Also create a route that creates Stripe Billing Portal sessions.

Frequently asked questions

How does the Stripe Customer Portal work?

Stripe's Customer Portal is a hosted page where customers manage their subscription. You configure it in the Stripe Dashboard with allowed actions (cancel, switch plans, update payment method). Your API route creates a billing portal session with the customer's stripe_customer_id and a return_url. Stripe generates a temporary URL that redirects the customer to the portal. No custom billing UI needed.

How are invoices synced from Stripe to Supabase?

The Stripe webhook handler listens for invoice.created and invoice.paid events. When an event fires, it looks up the customer by stripe_customer_id, then upserts the invoice data using ON CONFLICT (stripe_invoice_id) DO UPDATE. This means the same webhook event can be safely processed multiple times without creating duplicate records.

Why use request.text() instead of request.json() for Stripe webhooks?

Stripe's webhook signature verification requires the raw request body as a string. request.json() parses the JSON and loses the original formatting. request.text() returns the body as-is, which is what stripe.webhooks.constructEvent needs to verify the HMAC signature. Using request.json() will always fail signature verification.

Why does the webhook handler use the service role key?

Stripe webhooks are not authenticated as any Supabase user — they come from Stripe's servers. The service role key bypasses Row Level Security, allowing the webhook to insert and update records in any table. Without it, RLS policies would block the upsert since there is no authenticated user context.

How do I configure the Stripe Customer Portal?

Go to Stripe Dashboard, navigate to Settings, then Billing, then Customer Portal. Configure which features customers can access: updating payment methods, switching plans, canceling subscriptions, and viewing invoice history. Set the business information and branding. Save the configuration before creating portal sessions in your app.

Where should I store the Stripe API keys?

Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without the NEXT_PUBLIC_ prefix. These are server-side secrets used in API routes. Use V0's Connect panel to provision Stripe via Vercel Marketplace, which auto-configures the environment variables.

Can RapidDev help build a custom customer portal?

Yes. RapidDev has helped over 600 SaaS businesses build customer portals with subscription management, support systems, and usage dashboards. Book a free consultation at RapidDev to discuss your portal requirements.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.