# How to Build a Customer Portal with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or Free tier, Supabase free tier, Stripe account
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 1. Set Up the Supabase Schema for Customers and Tickets

*Storing customer and ticket data in Supabase gives you full control over the support experience while Stripe handles the billing complexity. The stripe_customer_id unique constraint enables reliable webhook upserts.*

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.

```
-- Customers linked to Stripe
create table customers (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users unique not null,
  company_name text,
  plan text default 'free',
  stripe_customer_id text unique,
  created_at timestamptz default now()
);

-- Support tickets
create table support_tickets (
  id uuid primary key default gen_random_uuid(),
  customer_id uuid references customers not null,
  subject text not null,
  description text not null,
  status text check (status in ('open','in_progress','resolved','closed')) default 'open',
  priority text check (priority in ('low','medium','high','urgent')) default 'medium',
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Ticket messages (threaded conversation)
create table ticket_messages (
  id uuid primary key default gen_random_uuid(),
  ticket_id uuid references support_tickets on delete cascade not null,
  sender_id uuid references auth.users not null,
  sender_type text check (sender_type in ('customer','agent')) not null,
  content text not null,
  attachments text[] default '{}',
  created_at timestamptz default now()
);

-- Invoices synced from Stripe
create table invoices (
  id uuid primary key default gen_random_uuid(),
  customer_id uuid references customers not null,
  stripe_invoice_id text unique not null,
  amount numeric not null,
  status text not null,
  pdf_url text,
  period_start date,
  period_end date,
  created_at timestamptz default now()
);

-- System announcements
create table announcements (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  content text not null,
  is_active boolean default true,
  created_at timestamptz default now()
);

-- RLS
alter table customers enable row level security;
alter table support_tickets enable row level security;
alter table ticket_messages enable row level security;
alter table invoices enable row level security;
alter table announcements enable row level security;

create policy "Users read own customer" on customers for select using (auth.uid() = user_id);
create policy "Users read own tickets" on support_tickets for select
  using (customer_id in (select id from customers where user_id = auth.uid()));
create policy "Users create tickets" on support_tickets for insert
  with check (customer_id in (select id from customers where user_id = auth.uid()));
create policy "Users read own messages" on ticket_messages for select
  using (ticket_id in (select id from support_tickets where customer_id in (select id from customers where user_id = auth.uid())));
create policy "Users create messages" on ticket_messages for insert
  with check (ticket_id in (select id from support_tickets where customer_id in (select id from customers where user_id = auth.uid())));
create policy "Users read own invoices" on invoices for select
  using (customer_id in (select id from customers where user_id = auth.uid()));
create 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

*The dashboard gives customers an immediate overview of their account status, reducing the need to contact support for basic questions like subscription status or recent ticket updates.*

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.

```
// app/portal/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import Link from 'next/link';
import { CreditCard, Headphones, FileText, Bell } from 'lucide-react';

export default async function PortalDashboard() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: customer } = await supabase
    .from('customers')
    .select('*')
    .eq('user_id', user.id)
    .single();

  if (!customer) redirect('/onboarding');

  // Fetch dashboard data in parallel
  const [ticketRes, invoiceRes, announcementRes] = await Promise.all([
    supabase
      .from('support_tickets')
      .select('id', { count: 'exact' })
      .eq('customer_id', customer.id)
      .in('status', ['open', 'in_progress']),
    supabase
      .from('invoices')
      .select('*')
      .eq('customer_id', customer.id)
      .order('created_at', { ascending: false })
      .limit(3),
    supabase
      .from('announcements')
      .select('*')
      .eq('is_active', true)
      .order('created_at', { ascending: false })
      .limit(1),
  ]);

  const openTickets = ticketRes.count ?? 0;
  const recentInvoices = invoiceRes.data ?? [];
  const announcement = announcementRes.data?.[0];

  return (
    <div className="max-w-5xl mx-auto p-6 space-y-6">
      <h1 className="text-3xl font-bold">Welcome back</h1>

      {announcement && (
        <Alert>
          <Bell className="h-4 w-4" />
          <AlertTitle>{announcement.title}</AlertTitle>
          <AlertDescription>{announcement.content}</AlertDescription>
        </Alert>
      )}

      <div className="grid md:grid-cols-3 gap-4">
        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <CreditCard className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Current Plan</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold capitalize">{customer.plan}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/billing">Manage Subscription</Link>
            </Button>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <Headphones className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Open Tickets</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{openTickets}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/tickets">View Tickets</Link>
            </Button>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <FileText className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Recent Invoices</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{recentInvoices.length}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/billing">View All</Link>
            </Button>
          </CardContent>
        </Card>
      </div>

      {recentInvoices.length > 0 && (
        <Card>
          <CardHeader><CardTitle>Latest Invoices</CardTitle></CardHeader>
          <CardContent>
            <div className="space-y-2">
              {recentInvoices.map((invoice) => (
                <div key={invoice.id} className="flex items-center justify-between py-2 border-b last:border-0">
                  <div>
                    <span className="font-medium">${Number(invoice.amount / 100).toFixed(2)}</span>
                    <span className="text-sm text-muted-foreground ml-2">
                      {new Date(invoice.created_at).toLocaleDateString()}
                    </span>
                  </div>
                  <div className="flex items-center gap-2">
                    <Badge variant={invoice.status === 'paid' ? 'default' : 'secondary'}>
                      {invoice.status}
                    </Badge>
                    {invoice.pdf_url && (
                      <Button variant="ghost" size="sm" asChild>
                        <a href={invoice.pdf_url} target="_blank" rel="noopener noreferrer">
                          <FileText className="h-4 w-4" />
                        </a>
                      </Button>
                    )}
                  </div>
                </div>
              ))}
            </div>
          </CardContent>
        </Card>
      )}
    </div>
  );
}
```

**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

*Threaded ticket conversations give customers and support agents a clear communication history. ScrollArea keeps long conversations navigable without page scrolling.*

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.

```
// app/portal/tickets/[id]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SendMessageForm } from './send-message-form';

const statusColors: Record<string, string> = {
  open: 'bg-blue-100 text-blue-800',
  in_progress: 'bg-yellow-100 text-yellow-800',
  resolved: 'bg-green-100 text-green-800',
  closed: 'bg-gray-100 text-gray-800',
};

const priorityColors: Record<string, string> = {
  low: 'bg-gray-100 text-gray-800',
  medium: 'bg-blue-100 text-blue-800',
  high: 'bg-orange-100 text-orange-800',
  urgent: 'bg-red-100 text-red-800',
};

export default async function TicketDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: ticket } = await supabase
    .from('support_tickets')
    .select('*')
    .eq('id', id)
    .single();

  if (!ticket) return <div className="p-12 text-center">Ticket not found</div>;

  const { data: messages } = await supabase
    .from('ticket_messages')
    .select('*')
    .eq('ticket_id', id)
    .order('created_at');

  const allMessages = messages ?? [];

  return (
    <div className="max-w-3xl mx-auto p-6 space-y-4">
      <div>
        <div className="flex items-center gap-2 mb-1">
          <h1 className="text-xl font-bold">{ticket.subject}</h1>
          <Badge className={statusColors[ticket.status]}>{ticket.status}</Badge>
          <Badge className={priorityColors[ticket.priority]}>{ticket.priority}</Badge>
        </div>
        <p className="text-sm text-muted-foreground">
          Opened {new Date(ticket.created_at).toLocaleDateString()}
        </p>
      </div>

      <Card>
        <CardContent className="p-0">
          <ScrollArea className="h-[400px] p-4">
            <div className="space-y-4">
              {/* Original description */}
              <div className="p-3 rounded-lg bg-muted">
                <p className="text-xs text-muted-foreground mb-1">You</p>
                <p className="text-sm whitespace-pre-wrap">{ticket.description}</p>
                <p className="text-xs text-muted-foreground mt-1">
                  {new Date(ticket.created_at).toLocaleString()}
                </p>
              </div>

              {/* Thread messages */}
              {allMessages.map((msg) => (
                <div
                  key={msg.id}
                  className={`p-3 rounded-lg ${
                    msg.sender_type === 'customer'
                      ? 'bg-muted ml-4'
                      : 'bg-primary/10 mr-4'
                  }`}
                >
                  <p className="text-xs text-muted-foreground mb-1">
                    {msg.sender_type === 'customer' ? 'You' : 'Support'}
                  </p>
                  <p className="text-sm whitespace-pre-wrap">{msg.content}</p>
                  <p className="text-xs text-muted-foreground mt-1">
                    {new Date(msg.created_at).toLocaleString()}
                  </p>
                </div>
              ))}
            </div>
          </ScrollArea>
        </CardContent>
      </Card>

      {ticket.status !== 'closed' && (
        <SendMessageForm ticketId={id} />
      )}
    </div>
  );
}

// app/portal/tickets/[id]/send-message-form.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { sendTicketMessage } from '@/app/actions/tickets';
import { Send } from 'lucide-react';

export function SendMessageForm({ ticketId }: { ticketId: string }) {
  const [content, setContent] = useState('');
  const [sending, setSending] = useState(false);

  async function handleSend() {
    if (!content.trim()) return;
    setSending(true);
    await sendTicketMessage(ticketId, content);
    setContent('');
    setSending(false);
  }

  return (
    <div className="flex gap-2">
      <Textarea
        value={content}
        onChange={e => setContent(e.target.value)}
        placeholder="Type your message..."
        className="min-h-[60px]"
      />
      <Button onClick={handleSend} disabled={sending || !content.trim()}>
        <Send className="h-4 w-4" />
      </Button>
    </div>
  );
}
```

**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

*Stripe webhooks automatically push invoice data to your database without polling. The ON CONFLICT upsert ensures idempotent processing even if Stripe retries the webhook delivery.*

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.

```
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  if (event.type === 'invoice.created' || event.type === 'invoice.paid') {
    const invoice = event.data.object as Stripe.Invoice;

    // Find customer by Stripe customer ID
    const { data: customer } = await supabase
      .from('customers')
      .select('id')
      .eq('stripe_customer_id', invoice.customer as string)
      .single();

    if (customer) {
      // Upsert invoice using stripe_invoice_id unique constraint
      await supabase.from('invoices').upsert({
        customer_id: customer.id,
        stripe_invoice_id: invoice.id,
        amount: invoice.amount_due,
        status: invoice.status ?? 'draft',
        pdf_url: invoice.invoice_pdf ?? null,
        period_start: invoice.period_start
          ? new Date(invoice.period_start * 1000).toISOString().split('T')[0]
          : null,
        period_end: invoice.period_end
          ? new Date(invoice.period_end * 1000).toISOString().split('T')[0]
          : null,
      }, { onConflict: 'stripe_invoice_id' });
    }
  }

  if (event.type === 'customer.subscription.updated') {
    const subscription = event.data.object as Stripe.Subscription;
    const planName = subscription.items.data[0]?.price?.lookup_key ?? 'unknown';

    await supabase
      .from('customers')
      .update({ plan: planName })
      .eq('stripe_customer_id', subscription.customer as string);
  }

  return NextResponse.json({ received: true });
}

// app/api/stripe/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { data: customer } = await supabase
    .from('customers')
    .select('stripe_customer_id')
    .eq('user_id', user.id)
    .single();

  if (!customer?.stripe_customer_id) {
    return NextResponse.json({ error: 'No Stripe customer' }, { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customer.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/portal/billing`,
  });

  return NextResponse.json({ url: session.url });
}
```

**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

*The billing page combines Stripe-synced invoice history with a one-click portal redirect, giving customers full subscription self-service without custom billing UI.*

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.

```
// app/portal/billing/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ManageSubscriptionButton } from './manage-subscription-button';
import { Download, CreditCard } from 'lucide-react';

export default async function BillingPage() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: customer } = await supabase
    .from('customers')
    .select('*')
    .eq('user_id', user.id)
    .single();

  if (!customer) redirect('/onboarding');

  const { data: invoices } = await supabase
    .from('invoices')
    .select('*')
    .eq('customer_id', customer.id)
    .order('created_at', { ascending: false });

  const allInvoices = invoices ?? [];

  return (
    <div className="max-w-4xl mx-auto p-6 space-y-6">
      <h1 className="text-2xl font-bold">Billing</h1>

      <Card>
        <CardHeader className="flex flex-row items-center justify-between">
          <div>
            <CardTitle className="text-lg">Subscription</CardTitle>
            <p className="text-sm text-muted-foreground mt-1">
              Current plan: <Badge className="capitalize">{customer.plan}</Badge>
            </p>
          </div>
          <ManageSubscriptionButton />
        </CardHeader>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle className="text-lg">Invoice History</CardTitle>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Date</TableHead>
                <TableHead>Period</TableHead>
                <TableHead>Amount</TableHead>
                <TableHead>Status</TableHead>
                <TableHead></TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {allInvoices.map((invoice) => (
                <TableRow key={invoice.id}>
                  <TableCell>
                    {new Date(invoice.created_at).toLocaleDateString()}
                  </TableCell>
                  <TableCell className="text-sm text-muted-foreground">
                    {invoice.period_start && invoice.period_end
                      ? `${new Date(invoice.period_start).toLocaleDateString()} - ${new Date(invoice.period_end).toLocaleDateString()}`
                      : '—'}
                  </TableCell>
                  <TableCell className="font-medium">
                    ${(Number(invoice.amount) / 100).toFixed(2)}
                  </TableCell>
                  <TableCell>
                    <Badge variant={invoice.status === 'paid' ? 'default' : 'secondary'}>
                      {invoice.status}
                    </Badge>
                  </TableCell>
                  <TableCell>
                    {invoice.pdf_url && (
                      <Button variant="ghost" size="sm" asChild>
                        <a href={invoice.pdf_url} target="_blank" rel="noopener noreferrer">
                          <Download className="h-4 w-4" />
                        </a>
                      </Button>
                    )}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </CardContent>
      </Card>
    </div>
  );
}

// app/portal/billing/manage-subscription-button.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { CreditCard } from 'lucide-react';

export function ManageSubscriptionButton() {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    const res = await fetch('/api/stripe/portal', { method: 'POST' });
    const { url } = await res.json();
    if (url) window.location.href = url;
    setLoading(false);
  }

  return (
    <Button onClick={handleClick} disabled={loading}>
      <CreditCard className="h-4 w-4 mr-2" />
      {loading ? 'Loading...' : 'Manage Subscription'}
    </Button>
  );
}
```

**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 example

File: `app/portal/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import Link from 'next/link';
import { CreditCard, Headphones, FileText, Bell } from 'lucide-react';

export default async function PortalDashboard() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect('/login');

  const { data: customer } = await supabase.from('customers').select('*').eq('user_id', user.id).single();
  if (!customer) redirect('/onboarding');

  const [ticketRes, invoiceRes, announcementRes] = await Promise.all([
    supabase.from('support_tickets').select('id', { count: 'exact' }).eq('customer_id', customer.id).in('status', ['open', 'in_progress']),
    supabase.from('invoices').select('*').eq('customer_id', customer.id).order('created_at', { ascending: false }).limit(3),
    supabase.from('announcements').select('*').eq('is_active', true).order('created_at', { ascending: false }).limit(1),
  ]);

  const openTickets = ticketRes.count ?? 0;
  const recentInvoices = invoiceRes.data ?? [];
  const announcement = announcementRes.data?.[0];

  return (
    <div className="max-w-5xl mx-auto p-6 space-y-6">
      <h1 className="text-3xl font-bold">Welcome back</h1>

      {announcement && (
        <Alert>
          <Bell className="h-4 w-4" />
          <AlertTitle>{announcement.title}</AlertTitle>
          <AlertDescription>{announcement.content}</AlertDescription>
        </Alert>
      )}

      <div className="grid md:grid-cols-3 gap-4">
        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <CreditCard className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Current Plan</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold capitalize">{customer.plan}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/billing">Manage</Link>
            </Button>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <Headphones className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Open Tickets</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{openTickets}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/tickets">View</Link>
            </Button>
          </CardContent>
        </Card>
        <Card>
          <CardHeader className="flex flex-row items-center gap-2 pb-2">
            <FileText className="h-5 w-5 text-primary" />
            <CardTitle className="text-sm text-muted-foreground">Invoices</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-2xl font-bold">{recentInvoices.length}</p>
            <Button variant="outline" size="sm" className="mt-2" asChild>
              <Link href="/portal/billing">View All</Link>
            </Button>
          </CardContent>
        </Card>
      </div>

      {recentInvoices.length > 0 && (
        <Card>
          <CardHeader><CardTitle>Latest Invoices</CardTitle></CardHeader>
          <CardContent className="space-y-2">
            {recentInvoices.map(inv => (
              <div key={inv.id} className="flex items-center justify-between py-2 border-b last:border-0">
                <div>
                  <span className="font-medium">${(Number(inv.amount) / 100).toFixed(2)}</span>
                  <span className="text-sm text-muted-foreground ml-2">{new Date(inv.created_at).toLocaleDateString()}</span>
                </div>
                <div className="flex items-center gap-2">
                  <Badge variant={inv.status === 'paid' ? 'default' : 'secondary'}>{inv.status}</Badge>
                  {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>}
                </div>
              </div>
            ))}
          </CardContent>
        </Card>
      )}
    </div>
  );
}
```

## Common mistakes

- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined

## 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/customer-portal
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/customer-portal
