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.
| Fact | Value |
|---|---|
| Tool | V0 |
| Build time | 1-2 hours |
| Difficulty | Intermediate |
| Last updated | April 2026 |
Project overview
Tech stack
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
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.
1-- Customers linked to Stripe2create 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);1011-- Support tickets12create 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);2223-- 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);3334-- Invoices synced from Stripe35create 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);4647-- System announcements48create 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);5556-- RLS57alter 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;6263create policy "Users read own customer" on customers for select using (auth.uid() = user_id);64create policy "Users read own tickets" on support_tickets for select65 using (customer_id in (select id from customers where user_id = auth.uid()));66create policy "Users create tickets" on support_tickets for insert67 with check (customer_id in (select id from customers where user_id = auth.uid()));68create policy "Users read own messages" on ticket_messages for select69 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 insert71 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 select73 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.
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.
1// app/portal/page.tsx2import { 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';1011export default async function PortalDashboard() {12 const supabase = await createClient();13 const { data: { user } } = await supabase.auth.getUser();14 if (!user) redirect('/login');1516 const { data: customer } = await supabase17 .from('customers')18 .select('*')19 .eq('user_id', user.id)20 .single();2122 if (!customer) redirect('/onboarding');2324 // Fetch dashboard data in parallel25 const [ticketRes, invoiceRes, announcementRes] = await Promise.all([26 supabase27 .from('support_tickets')28 .select('id', { count: 'exact' })29 .eq('customer_id', customer.id)30 .in('status', ['open', 'in_progress']),31 supabase32 .from('invoices')33 .select('*')34 .eq('customer_id', customer.id)35 .order('created_at', { ascending: false })36 .limit(3),37 supabase38 .from('announcements')39 .select('*')40 .eq('is_active', true)41 .order('created_at', { ascending: false })42 .limit(1),43 ]);4445 const openTickets = ticketRes.count ?? 0;46 const recentInvoices = invoiceRes.data ?? [];47 const announcement = announcementRes.data?.[0];4849 return (50 <div className="max-w-5xl mx-auto p-6 space-y-6">51 <h1 className="text-3xl font-bold">Welcome back</h1>5253 {announcement && (54 <Alert>55 <Bell className="h-4 w-4" />56 <AlertTitle>{announcement.title}</AlertTitle>57 <AlertDescription>{announcement.content}</AlertDescription>58 </Alert>59 )}6061 <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>7475 <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>8788 <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>101102 {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.
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.
1// app/portal/tickets/[id]/page.tsx2import { 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';89const 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};1516const 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};2223export 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');3233 const { data: ticket } = await supabase34 .from('support_tickets')35 .select('*')36 .eq('id', id)37 .single();3839 if (!ticket) return <div className="p-12 text-center">Ticket not found</div>;4041 const { data: messages } = await supabase42 .from('ticket_messages')43 .select('*')44 .eq('ticket_id', id)45 .order('created_at');4647 const allMessages = messages ?? [];4849 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>6162 <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>7475 {/* Thread messages */}76 {allMessages.map((msg) => (77 <div78 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>9899 {ticket.status !== 'closed' && (100 <SendMessageForm ticketId={id} />101 )}102 </div>103 );104}105106// app/portal/tickets/[id]/send-message-form.tsx107'use client';108109import { 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';114115export function SendMessageForm({ ticketId }: { ticketId: string }) {116 const [content, setContent] = useState('');117 const [sending, setSending] = useState(false);118119 async function handleSend() {120 if (!content.trim()) return;121 setSending(true);122 await sendTicketMessage(ticketId, content);123 setContent('');124 setSending(false);125 }126127 return (128 <div className="flex gap-2">129 <Textarea130 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.
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.
1// app/api/webhooks/stripe/route.ts2import { NextRequest, NextResponse } from 'next/server';3import Stripe from 'stripe';4import { createClient } from '@supabase/supabase-js';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);78export async function POST(request: NextRequest) {9 const body = await request.text();10 const sig = request.headers.get('stripe-signature')!;1112 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 }2021 const supabase = createClient(22 process.env.NEXT_PUBLIC_SUPABASE_URL!,23 process.env.SUPABASE_SERVICE_ROLE_KEY!24 );2526 if (event.type === 'invoice.created' || event.type === 'invoice.paid') {27 const invoice = event.data.object as Stripe.Invoice;2829 // Find customer by Stripe customer ID30 const { data: customer } = await supabase31 .from('customers')32 .select('id')33 .eq('stripe_customer_id', invoice.customer as string)34 .single();3536 if (customer) {37 // Upsert invoice using stripe_invoice_id unique constraint38 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_start45 ? new Date(invoice.period_start * 1000).toISOString().split('T')[0]46 : null,47 period_end: invoice.period_end48 ? new Date(invoice.period_end * 1000).toISOString().split('T')[0]49 : null,50 }, { onConflict: 'stripe_invoice_id' });51 }52 }5354 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';5758 await supabase59 .from('customers')60 .update({ plan: planName })61 .eq('stripe_customer_id', subscription.customer as string);62 }6364 return NextResponse.json({ received: true });65}6667// app/api/stripe/portal/route.ts68import { NextRequest, NextResponse } from 'next/server';69import { createClient } from '@/lib/supabase/server';70import Stripe from 'stripe';7172const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);7374export 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 });7879 const { data: customer } = await supabase80 .from('customers')81 .select('stripe_customer_id')82 .eq('user_id', user.id)83 .single();8485 if (!customer?.stripe_customer_id) {86 return NextResponse.json({ error: 'No Stripe customer' }, { status: 400 });87 }8889 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 });9394 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.
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.
1// app/portal/billing/page.tsx2import { 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';1011export default async function BillingPage() {12 const supabase = await createClient();13 const { data: { user } } = await supabase.auth.getUser();14 if (!user) redirect('/login');1516 const { data: customer } = await supabase17 .from('customers')18 .select('*')19 .eq('user_id', user.id)20 .single();2122 if (!customer) redirect('/onboarding');2324 const { data: invoices } = await supabase25 .from('invoices')26 .select('*')27 .eq('customer_id', customer.id)28 .order('created_at', { ascending: false });2930 const allInvoices = invoices ?? [];3132 return (33 <div className="max-w-4xl mx-auto p-6 space-y-6">34 <h1 className="text-2xl font-bold">Billing</h1>3536 <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>4748 <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_end71 ? `${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}100101// app/portal/billing/manage-subscription-button.tsx102'use client';103104import { useState } from 'react';105import { Button } from '@/components/ui/button';106import { CreditCard } from 'lucide-react';107108export function ManageSubscriptionButton() {109 const [loading, setLoading] = useState(false);110111 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 }118119 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
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';910export default async function PortalDashboard() {11 const supabase = await createClient();12 const { data: { user } } = await supabase.auth.getUser();13 if (!user) redirect('/login');1415 const { data: customer } = await supabase.from('customers').select('*').eq('user_id', user.id).single();16 if (!customer) redirect('/onboarding');1718 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 ]);2324 const openTickets = ticketRes.count ?? 0;25 const recentInvoices = invoiceRes.data ?? [];26 const announcement = announcementRes.data?.[0];2728 return (29 <div className="max-w-5xl mx-auto p-6 space-y-6">30 <h1 className="text-3xl font-bold">Welcome back</h1>3132 {announcement && (33 <Alert>34 <Bell className="h-4 w-4" />35 <AlertTitle>{announcement.title}</AlertTitle>36 <AlertDescription>{announcement.content}</AlertDescription>37 </Alert>38 )}3940 <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>7879 {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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation