# How to Build a Client Invoicing Tool 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 professional invoicing tool that lets freelancers create branded invoices with auto-calculated totals, send them to clients via email, and collect payments through Stripe Payment Links. V0 generates the invoice builder form with dynamic line items while Supabase handles sequential invoice numbering with advisory locks to prevent race conditions.

## Before you start

- A V0 account (free or Premium)
- A Supabase project with the database tables created
- A Stripe account with Payment Links enabled
- A Resend account and API key for email delivery
- Basic familiarity with React forms and TypeScript

## Step-by-step guide

### 1. Set Up the Supabase Schema with Generated Columns

*Generated columns in PostgreSQL automatically calculate tax and totals from line item data, eliminating manual math errors and keeping your invoice amounts always in sync.*

Create the database tables for clients, invoices, line items, and business settings. The invoices table uses GENERATED ALWAYS AS columns for tax_amount and total so the database enforces correct calculations. Create a Supabase function with advisory_lock for sequential invoice numbering.

```
-- Create clients table
create table clients (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  company_name text not null,
  contact_name text not null,
  email text not null,
  address jsonb default '{}',
  created_at timestamptz default now()
);

-- Create invoices table with generated columns
create table invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  client_id uuid references clients not null,
  invoice_number text unique not null,
  status text check (status in ('draft','sent','viewed','paid','overdue')) default 'draft',
  issue_date date default current_date,
  due_date date default (current_date + interval '30 days'),
  subtotal numeric default 0,
  tax_rate numeric default 0,
  tax_amount numeric generated always as (subtotal * tax_rate / 100) stored,
  total numeric generated always as (subtotal + subtotal * tax_rate / 100) stored,
  notes text,
  stripe_payment_link text,
  paid_at timestamptz,
  created_at timestamptz default now()
);

-- Create line items with generated amount
create table line_items (
  id uuid primary key default gen_random_uuid(),
  invoice_id uuid references invoices on delete cascade not null,
  description text not null,
  quantity numeric not null default 1,
  unit_price numeric not null default 0,
  amount numeric generated always as (quantity * unit_price) stored
);

-- Business settings
create table settings (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users unique not null,
  business_name text,
  logo_url text,
  address jsonb default '{}',
  default_tax_rate numeric default 0,
  payment_terms int default 30
);

-- Sequential invoice numbering function
create or replace function next_invoice_number(p_user_id uuid)
returns text
language plpgsql
as $$
declare
  v_year text := to_char(now(), 'YYYY');
  v_max int;
  v_next text;
begin
  perform pg_advisory_xact_lock(hashtext(p_user_id::text || v_year));
  
  select coalesce(
    max(cast(split_part(invoice_number, '-', 3) as int)), 0
  ) into v_max
  from invoices
  where user_id = p_user_id
    and invoice_number like 'INV-' || v_year || '-%';
  
  v_next := 'INV-' || v_year || '-' || lpad((v_max + 1)::text, 4, '0');
  return v_next;
end;
$$;

-- Enable RLS
alter table clients enable row level security;
alter table invoices enable row level security;
alter table line_items enable row level security;
alter table settings enable row level security;

create policy "Users manage own clients" on clients for all using (auth.uid() = user_id);
create policy "Users manage own invoices" on invoices for all using (auth.uid() = user_id);
create policy "Users manage own line items" on line_items for all
  using (invoice_id in (select id from invoices where user_id = auth.uid()));
create policy "Users manage own settings" on settings for all using (auth.uid() = user_id);
```

**Expected result:** All tables created with generated columns for automatic calculations and a race-condition-safe invoice numbering function.

### 2. Build the Invoice Builder Form with Dynamic Line Items

*The invoice builder is the core of the app. Dynamic line items let users add, remove, and edit rows while seeing real-time totals, making the invoicing process feel professional and intuitive.*

Prompt V0 to generate an invoice creation page with a client selector, date pickers, and a dynamic line items table where rows can be added and removed. Each row auto-calculates its amount, and the footer shows subtotal, tax, and total. Use prompt queuing to first generate the form layout, then add the calculation logic.

```
// app/invoices/new/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { Trash2, Plus } from 'lucide-react';

type LineItem = {
  id: string;
  description: string;
  quantity: number;
  unit_price: number;
};

export default function NewInvoicePage() {
  const router = useRouter();
  const supabase = createClient();
  const [clientId, setClientId] = useState('');
  const [taxRate, setTaxRate] = useState(0);
  const [notes, setNotes] = useState('');
  const [items, setItems] = useState<LineItem[]>([
    { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }
  ]);
  const [saving, setSaving] = useState(false);

  const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0);
  const taxAmount = subtotal * taxRate / 100;
  const total = subtotal + taxAmount;

  function addItem() {
    setItems([...items, { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }]);
  }

  function removeItem(id: string) {
    if (items.length > 1) setItems(items.filter(i => i.id !== id));
  }

  function updateItem(id: string, field: keyof LineItem, value: string | number) {
    setItems(items.map(i => i.id === id ? { ...i, [field]: value } : i));
  }

  async function handleSubmit() {
    setSaving(true);
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return;

    // Get next invoice number via RPC
    const { data: invoiceNumber } = await supabase.rpc('next_invoice_number', {
      p_user_id: user.id
    });

    // Create invoice
    const { data: invoice, error } = await supabase.from('invoices').insert({
      user_id: user.id,
      client_id: clientId,
      invoice_number: invoiceNumber,
      subtotal,
      tax_rate: taxRate,
      notes
    }).select().single();

    if (error || !invoice) { setSaving(false); return; }

    // Insert line items
    await supabase.from('line_items').insert(
      items.map(item => ({
        invoice_id: invoice.id,
        description: item.description,
        quantity: item.quantity,
        unit_price: item.unit_price
      }))
    );

    router.push(`/invoices/${invoice.id}`);
  }

  return (
    <div className="max-w-4xl mx-auto p-6">
      <Card>
        <CardHeader>
          <CardTitle>New Invoice</CardTitle>
        </CardHeader>
        <CardContent className="space-y-6">
          <div className="grid grid-cols-2 gap-4">
            <Select value={clientId} onValueChange={setClientId}>
              <SelectTrigger><SelectValue placeholder="Select client" /></SelectTrigger>
              <SelectContent>
                {/* Client options loaded from Supabase */}
              </SelectContent>
            </Select>
            <Input type="number" placeholder="Tax rate %" value={taxRate}
              onChange={e => setTaxRate(Number(e.target.value))} />
          </div>

          <Table>
            <TableHeader>
              <TableRow>
                <TableHead className="w-[40%]">Description</TableHead>
                <TableHead>Qty</TableHead>
                <TableHead>Unit Price</TableHead>
                <TableHead>Amount</TableHead>
                <TableHead></TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {items.map(item => (
                <TableRow key={item.id}>
                  <TableCell>
                    <Input value={item.description}
                      onChange={e => updateItem(item.id, 'description', e.target.value)} />
                  </TableCell>
                  <TableCell>
                    <Input type="number" className="w-20" value={item.quantity}
                      onChange={e => updateItem(item.id, 'quantity', Number(e.target.value))} />
                  </TableCell>
                  <TableCell>
                    <Input type="number" className="w-28" value={item.unit_price}
                      onChange={e => updateItem(item.id, 'unit_price', Number(e.target.value))} />
                  </TableCell>
                  <TableCell className="font-medium">
                    ${(item.quantity * item.unit_price).toFixed(2)}
                  </TableCell>
                  <TableCell>
                    <Button variant="ghost" size="icon" onClick={() => removeItem(item.id)}>
                      <Trash2 className="h-4 w-4" />
                    </Button>
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>

          <Button variant="outline" onClick={addItem}>
            <Plus className="h-4 w-4 mr-2" /> Add Line Item
          </Button>

          <Textarea placeholder="Notes (optional)" value={notes}
            onChange={e => setNotes(e.target.value)} />
        </CardContent>
        <CardFooter className="flex flex-col items-end gap-1">
          <div className="text-sm text-muted-foreground">Subtotal: ${subtotal.toFixed(2)}</div>
          <div className="text-sm text-muted-foreground">Tax ({taxRate}%): ${taxAmount.toFixed(2)}</div>
          <div className="text-lg font-bold">Total: ${total.toFixed(2)}</div>
          <Button onClick={handleSubmit} disabled={saving} className="mt-4">
            {saving ? 'Creating...' : 'Create Invoice'}
          </Button>
        </CardFooter>
      </Card>
    </div>
  );
}
```

**Expected result:** A fully interactive invoice builder with dynamic line items, auto-calculated totals, and sequential invoice numbering via Supabase RPC.

### 3. Generate PDF Invoices with @react-pdf/renderer

*Professional invoices need to be downloadable as PDFs. Using @react-pdf/renderer in an API Route keeps the rendering server-side so the client just receives a PDF file.*

Create an API route that fetches invoice data from Supabase and renders a branded PDF using @react-pdf/renderer. The PDF includes your business logo, client details, line items table, and payment information. Install the package by prompting V0 to add @react-pdf/renderer to the project.

```
// app/api/invoices/[id]/pdf/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { renderToBuffer } from '@react-pdf/renderer';
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
import React from 'react';

const styles = StyleSheet.create({
  page: { padding: 40, fontFamily: 'Helvetica', fontSize: 10 },
  header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 30 },
  title: { fontSize: 24, fontWeight: 'bold', color: '#111827' },
  invoiceNumber: { fontSize: 12, color: '#6B7280', marginTop: 4 },
  section: { marginBottom: 20 },
  label: { fontSize: 8, color: '#6B7280', textTransform: 'uppercase', marginBottom: 4 },
  tableHeader: { flexDirection: 'row', borderBottomWidth: 1, borderColor: '#E5E7EB', paddingBottom: 6, marginBottom: 8 },
  tableRow: { flexDirection: 'row', paddingVertical: 4 },
  colDesc: { flex: 3 },
  colQty: { flex: 1, textAlign: 'center' },
  colPrice: { flex: 1, textAlign: 'right' },
  colAmount: { flex: 1, textAlign: 'right' },
  totals: { marginTop: 20, alignItems: 'flex-end' },
  totalRow: { flexDirection: 'row', width: 200, justifyContent: 'space-between', paddingVertical: 2 },
  grandTotal: { fontSize: 14, fontWeight: 'bold' },
});

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const supabase = await createClient();

  const { data: invoice } = await supabase
    .from('invoices')
    .select('*, clients(*), line_items(*)')
    .eq('id', id)
    .single();

  if (!invoice) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  const doc = React.createElement(Document, {},
    React.createElement(Page, { size: 'A4', style: styles.page },
      React.createElement(View, { style: styles.header },
        React.createElement(View, {},
          React.createElement(Text, { style: styles.title }, 'INVOICE'),
          React.createElement(Text, { style: styles.invoiceNumber }, invoice.invoice_number)
        ),
        React.createElement(View, { style: { alignItems: 'flex-end' } },
          React.createElement(Text, {}, `Issue: ${invoice.issue_date}`),
          React.createElement(Text, {}, `Due: ${invoice.due_date}`)
        )
      ),
      React.createElement(View, { style: styles.section },
        React.createElement(Text, { style: styles.label }, 'Bill To'),
        React.createElement(Text, {}, invoice.clients.company_name),
        React.createElement(Text, {}, invoice.clients.contact_name),
        React.createElement(Text, {}, invoice.clients.email)
      ),
      React.createElement(View, { style: styles.tableHeader },
        React.createElement(Text, { style: styles.colDesc }, 'Description'),
        React.createElement(Text, { style: styles.colQty }, 'Qty'),
        React.createElement(Text, { style: styles.colPrice }, 'Price'),
        React.createElement(Text, { style: styles.colAmount }, 'Amount')
      ),
      ...invoice.line_items.map((item: any) =>
        React.createElement(View, { key: item.id, style: styles.tableRow },
          React.createElement(Text, { style: styles.colDesc }, item.description),
          React.createElement(Text, { style: styles.colQty }, String(item.quantity)),
          React.createElement(Text, { style: styles.colPrice }, `$${item.unit_price.toFixed(2)}`),
          React.createElement(Text, { style: styles.colAmount }, `$${item.amount.toFixed(2)}`)
        )
      ),
      React.createElement(View, { style: styles.totals },
        React.createElement(View, { style: styles.totalRow },
          React.createElement(Text, {}, 'Subtotal'),
          React.createElement(Text, {}, `$${invoice.subtotal.toFixed(2)}`)
        ),
        React.createElement(View, { style: styles.totalRow },
          React.createElement(Text, {}, `Tax (${invoice.tax_rate}%)`),
          React.createElement(Text, {}, `$${invoice.tax_amount.toFixed(2)}`)
        ),
        React.createElement(View, { style: styles.totalRow },
          React.createElement(Text, { style: styles.grandTotal }, 'Total'),
          React.createElement(Text, { style: styles.grandTotal }, `$${invoice.total.toFixed(2)}`)
        )
      )
    )
  );

  const buffer = await renderToBuffer(doc);

  return new NextResponse(buffer, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `inline; filename="${invoice.invoice_number}.pdf"`,
    },
  });
}
```

**Expected result:** Visiting /api/invoices/{id}/pdf renders a professionally formatted PDF with business info, client details, line items, and calculated totals.

### 4. Add the Invoice List Dashboard with Status Tracking

*Freelancers need a dashboard to see all their invoices at a glance with clear status indicators so they can quickly identify which invoices are overdue or awaiting payment.*

Build the invoices list page as a Server Component that fetches all invoices with their client names. Use shadcn/ui Table with color-coded Badge components for each status. Add filter tabs for All, Draft, Sent, Paid, and Overdue.

```
// app/invoices/page.tsx
import { createClient } from '@/lib/supabase/server';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import Link from 'next/link';
import { Plus, Download, Send } from 'lucide-react';

const statusColors: Record<string, string> = {
  draft: 'bg-gray-100 text-gray-800',
  sent: 'bg-blue-100 text-blue-800',
  viewed: 'bg-yellow-100 text-yellow-800',
  paid: 'bg-green-100 text-green-800',
  overdue: 'bg-red-100 text-red-800',
};

export default async function InvoicesPage() {
  const supabase = await createClient();
  const { data: invoices } = await supabase
    .from('invoices')
    .select('*, clients(company_name, contact_name)')
    .order('created_at', { ascending: false });

  const allInvoices = invoices ?? [];
  const totalOutstanding = allInvoices
    .filter(inv => ['sent', 'viewed', 'overdue'].includes(inv.status))
    .reduce((sum, inv) => sum + Number(inv.total), 0);
  const totalPaid = allInvoices
    .filter(inv => inv.status === 'paid')
    .reduce((sum, inv) => sum + Number(inv.total), 0);

  function renderTable(filtered: typeof allInvoices) {
    return (
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Invoice</TableHead>
            <TableHead>Client</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Issue Date</TableHead>
            <TableHead>Due Date</TableHead>
            <TableHead className="text-right">Total</TableHead>
            <TableHead></TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {filtered.map(invoice => (
            <TableRow key={invoice.id}>
              <TableCell className="font-medium">
                <Link href={`/invoices/${invoice.id}`} className="hover:underline">
                  {invoice.invoice_number}
                </Link>
              </TableCell>
              <TableCell>{invoice.clients?.company_name}</TableCell>
              <TableCell>
                <Badge className={statusColors[invoice.status]}>
                  {invoice.status}
                </Badge>
              </TableCell>
              <TableCell>{invoice.issue_date}</TableCell>
              <TableCell>{invoice.due_date}</TableCell>
              <TableCell className="text-right font-medium">
                ${Number(invoice.total).toFixed(2)}
              </TableCell>
              <TableCell>
                <div className="flex gap-1">
                  <Button variant="ghost" size="icon" asChild>
                    <a href={`/api/invoices/${invoice.id}/pdf`}><Download className="h-4 w-4" /></a>
                  </Button>
                </div>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    );
  }

  return (
    <div className="max-w-6xl mx-auto p-6 space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-3xl font-bold">Invoices</h1>
        <Button asChild><Link href="/invoices/new"><Plus className="h-4 w-4 mr-2" /> New Invoice</Link></Button>
      </div>

      <div className="grid grid-cols-3 gap-4">
        <Card>
          <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Outstanding</CardTitle></CardHeader>
          <CardContent><p className="text-2xl font-bold">${totalOutstanding.toFixed(2)}</p></CardContent>
        </Card>
        <Card>
          <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Paid</CardTitle></CardHeader>
          <CardContent><p className="text-2xl font-bold text-green-600">${totalPaid.toFixed(2)}</p></CardContent>
        </Card>
        <Card>
          <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Invoices</CardTitle></CardHeader>
          <CardContent><p className="text-2xl font-bold">{allInvoices.length}</p></CardContent>
        </Card>
      </div>

      <Tabs defaultValue="all">
        <TabsList>
          <TabsTrigger value="all">All ({allInvoices.length})</TabsTrigger>
          <TabsTrigger value="draft">Draft</TabsTrigger>
          <TabsTrigger value="sent">Sent</TabsTrigger>
          <TabsTrigger value="paid">Paid</TabsTrigger>
          <TabsTrigger value="overdue">Overdue</TabsTrigger>
        </TabsList>
        <TabsContent value="all">{renderTable(allInvoices)}</TabsContent>
        <TabsContent value="draft">{renderTable(allInvoices.filter(i => i.status === 'draft'))}</TabsContent>
        <TabsContent value="sent">{renderTable(allInvoices.filter(i => i.status === 'sent'))}</TabsContent>
        <TabsContent value="paid">{renderTable(allInvoices.filter(i => i.status === 'paid'))}</TabsContent>
        <TabsContent value="overdue">{renderTable(allInvoices.filter(i => i.status === 'overdue'))}</TabsContent>
      </Tabs>
    </div>
  );
}
```

**Expected result:** A dashboard showing all invoices with summary cards for outstanding and paid amounts, filterable by status with color-coded badges.

### 5. Create the Public Payment Page with Stripe Payment Links

*Clients need a simple, no-login-required page where they can view an invoice and pay it instantly. Stripe Payment Links eliminate the need for a complex checkout implementation.*

Build a public-facing payment page at /invoices/[id]/pay that does not require authentication. It displays the invoice details and a Pay Now button that redirects to the Stripe Payment Link. Create a Server Action that generates the Stripe Payment Link when the invoice is sent.

```
// app/invoices/[id]/pay/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { CheckCircle } from 'lucide-react';

export default async function PaymentPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const supabase = await createClient();

  const { data: invoice } = await supabase
    .from('invoices')
    .select('*, clients(company_name), line_items(*)')
    .eq('id', id)
    .single();

  if (!invoice) {
    return <div className="p-12 text-center">Invoice not found.</div>;
  }

  // Mark as viewed if it was sent
  if (invoice.status === 'sent') {
    await supabase
      .from('invoices')
      .update({ status: 'viewed' })
      .eq('id', id);
  }

  if (invoice.status === 'paid') {
    return (
      <div className="max-w-2xl mx-auto p-12 text-center space-y-4">
        <CheckCircle className="h-16 w-16 text-green-500 mx-auto" />
        <h1 className="text-2xl font-bold">Payment Received</h1>
        <p className="text-muted-foreground">
          Invoice {invoice.invoice_number} was paid on{' '}
          {new Date(invoice.paid_at).toLocaleDateString()}.
        </p>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto p-6">
      <Card>
        <CardHeader>
          <div className="flex items-center justify-between">
            <CardTitle>{invoice.invoice_number}</CardTitle>
            <Badge>{invoice.status}</Badge>
          </div>
          <p className="text-sm text-muted-foreground">
            Due {new Date(invoice.due_date).toLocaleDateString()}
          </p>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Description</TableHead>
                <TableHead className="text-center">Qty</TableHead>
                <TableHead className="text-right">Price</TableHead>
                <TableHead className="text-right">Amount</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {invoice.line_items.map((item: any) => (
                <TableRow key={item.id}>
                  <TableCell>{item.description}</TableCell>
                  <TableCell className="text-center">{item.quantity}</TableCell>
                  <TableCell className="text-right">${item.unit_price.toFixed(2)}</TableCell>
                  <TableCell className="text-right">${item.amount.toFixed(2)}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
          <div className="mt-4 flex flex-col items-end gap-1 text-sm">
            <span>Subtotal: ${Number(invoice.subtotal).toFixed(2)}</span>
            <span>Tax ({invoice.tax_rate}%): ${Number(invoice.tax_amount).toFixed(2)}</span>
            <span className="text-lg font-bold">Total: ${Number(invoice.total).toFixed(2)}</span>
          </div>
        </CardContent>
        <CardFooter>
          {invoice.stripe_payment_link ? (
            <Button asChild className="w-full" size="lg">
              <a href={invoice.stripe_payment_link}>Pay ${Number(invoice.total).toFixed(2)} Now</a>
            </Button>
          ) : (
            <p className="text-sm text-muted-foreground">Contact sender for payment instructions.</p>
          )}
        </CardFooter>
      </Card>
    </div>
  );
}
```

**Expected result:** A public page where clients can view their invoice details and click Pay Now to complete payment through Stripe, without needing an account.

### 6. Wire Up Email Delivery and Environment Variables

*Sending invoices by email closes the billing loop. Storing API keys in the Vars tab instead of code keeps secrets secure and follows V0 best practices.*

Create a Server Action that sends the invoice via Resend email with a link to the payment page. Set RESEND_API_KEY and STRIPE_SECRET_KEY in V0's Vars tab without the NEXT_PUBLIC_ prefix since both are used server-side only.

```
// app/actions/send-invoice.ts
'use server';

import { createClient } from '@/lib/supabase/server';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendInvoice(invoiceId: string) {
  const supabase = await createClient();

  const { data: invoice } = await supabase
    .from('invoices')
    .select('*, clients(email, contact_name, company_name)')
    .eq('id', invoiceId)
    .single();

  if (!invoice) throw new Error('Invoice not found');

  const paymentUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invoices/${invoiceId}/pay`;

  // Fetch business settings for branding
  const { data: settings } = await supabase
    .from('settings')
    .select('*')
    .eq('user_id', invoice.user_id)
    .single();

  await resend.emails.send({
    from: `${settings?.business_name || 'Invoice'} <invoices@yourdomain.com>`,
    to: invoice.clients.email,
    subject: `Invoice ${invoice.invoice_number} - $${Number(invoice.total).toFixed(2)}`,
    html: `
      <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
        <h2>Invoice ${invoice.invoice_number}</h2>
        <p>Hi ${invoice.clients.contact_name},</p>
        <p>You have a new invoice from ${settings?.business_name || 'us'} for <strong>$${Number(invoice.total).toFixed(2)}</strong>.</p>
        <p>Due date: ${new Date(invoice.due_date).toLocaleDateString()}</p>
        <a href="${paymentUrl}" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; margin-top: 16px;">View & Pay Invoice</a>
        <p style="margin-top: 24px; color: #6B7280; font-size: 14px;">If you have questions about this invoice, reply to this email.</p>
      </div>
    `,
  });

  // Update status to sent
  await supabase
    .from('invoices')
    .update({ status: 'sent' })
    .eq('id', invoiceId);

  return { success: true };
}
```

**Expected result:** Invoices are sent via email with a branded message and payment link. The invoice status automatically updates to 'sent' after delivery.

## Complete code example

File: `app/invoices/new/page.tsx`

```typescript
'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { Trash2, Plus } from 'lucide-react';

type LineItem = { id: string; description: string; quantity: number; unit_price: number };
type Client = { id: string; company_name: string; contact_name: string };

export default function NewInvoicePage() {
  const router = useRouter();
  const supabase = createClient();
  const [clients, setClients] = useState<Client[]>([]);
  const [clientId, setClientId] = useState('');
  const [taxRate, setTaxRate] = useState(0);
  const [notes, setNotes] = useState('');
  const [saving, setSaving] = useState(false);
  const [items, setItems] = useState<LineItem[]>([
    { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 },
  ]);

  useEffect(() => {
    supabase.from('clients').select('id, company_name, contact_name').then(({ data }) => {
      if (data) setClients(data);
    });
    supabase.from('settings').select('default_tax_rate').limit(1).single().then(({ data }) => {
      if (data?.default_tax_rate) setTaxRate(data.default_tax_rate);
    });
  }, []);

  const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unit_price, 0);
  const taxAmount = subtotal * taxRate / 100;
  const total = subtotal + taxAmount;

  function addItem() {
    setItems(prev => [...prev, { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }]);
  }
  function removeItem(id: string) {
    if (items.length > 1) setItems(prev => prev.filter(i => i.id !== id));
  }
  function updateItem(id: string, field: keyof LineItem, value: string | number) {
    setItems(prev => prev.map(i => (i.id === id ? { ...i, [field]: value } : i)));
  }

  async function handleSubmit() {
    if (!clientId || items.some(i => !i.description)) return;
    setSaving(true);
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) { setSaving(false); return; }

    const { data: invoiceNumber } = await supabase.rpc('next_invoice_number', { p_user_id: user.id });
    const { data: invoice, error } = await supabase.from('invoices').insert({
      user_id: user.id, client_id: clientId, invoice_number: invoiceNumber,
      subtotal, tax_rate: taxRate, notes,
    }).select().single();

    if (error || !invoice) { setSaving(false); return; }

    await supabase.from('line_items').insert(
      items.map(i => ({ invoice_id: invoice.id, description: i.description, quantity: i.quantity, unit_price: i.unit_price }))
    );
    router.push(`/invoices/${invoice.id}`);
  }

  return (
    <div className="max-w-4xl mx-auto p-6">
      <Card>
        <CardHeader><CardTitle>New Invoice</CardTitle></CardHeader>
        <CardContent className="space-y-6">
          <div className="grid grid-cols-2 gap-4">
            <Select value={clientId} onValueChange={setClientId}>
              <SelectTrigger><SelectValue placeholder="Select client" /></SelectTrigger>
              <SelectContent>
                {clients.map(c => (
                  <SelectItem key={c.id} value={c.id}>{c.company_name} — {c.contact_name}</SelectItem>
                ))}
              </SelectContent>
            </Select>
            <Input type="number" placeholder="Tax rate %" value={taxRate} onChange={e => setTaxRate(Number(e.target.value))} />
          </div>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead className="w-[40%]">Description</TableHead>
                <TableHead>Qty</TableHead>
                <TableHead>Unit Price</TableHead>
                <TableHead>Amount</TableHead>
                <TableHead />
              </TableRow>
            </TableHeader>
            <TableBody>
              {items.map(item => (
                <TableRow key={item.id}>
                  <TableCell><Input value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} placeholder="Service description" /></TableCell>
                  <TableCell><Input type="number" className="w-20" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', Number(e.target.value))} /></TableCell>
                  <TableCell><Input type="number" className="w-28" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', Number(e.target.value))} /></TableCell>
                  <TableCell className="font-medium">${(item.quantity * item.unit_price).toFixed(2)}</TableCell>
                  <TableCell><Button variant="ghost" size="icon" onClick={() => removeItem(item.id)}><Trash2 className="h-4 w-4" /></Button></TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
          <Button variant="outline" onClick={addItem}><Plus className="h-4 w-4 mr-2" /> Add Line Item</Button>
          <Textarea placeholder="Notes for the client (optional)" value={notes} onChange={e => setNotes(e.target.value)} />
        </CardContent>
        <CardFooter className="flex flex-col items-end gap-1">
          <span className="text-sm text-muted-foreground">Subtotal: ${subtotal.toFixed(2)}</span>
          <span className="text-sm text-muted-foreground">Tax ({taxRate}%): ${taxAmount.toFixed(2)}</span>
          <span className="text-lg font-bold">Total: ${total.toFixed(2)}</span>
          <Button onClick={handleSubmit} disabled={saving || !clientId} className="mt-4">{saving ? 'Creating...' : 'Create Invoice'}</Button>
        </CardFooter>
      </Card>
    </div>
  );
}
```

## Common mistakes

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

## Best practices

- Use PostgreSQL generated columns for all calculated fields so totals are always mathematically correct
- Keep the public payment page unauthenticated — do not require clients to create accounts to pay invoices
- Store all API keys (RESEND_API_KEY, STRIPE_SECRET_KEY) in V0's Vars tab without the NEXT_PUBLIC_ prefix
- Use advisory locks in the invoice numbering function to guarantee sequential numbers under concurrent load
- Generate PDFs server-side in API routes to keep the bundle size small and avoid browser compatibility issues
- Set RLS policies so users can only access their own invoices and clients
- Use prompt queuing in V0 to generate the invoice form, PDF renderer, payment page, and email sending as separate focused prompts

## Frequently asked questions

### How does V0 handle PDF generation for invoices?

V0 generates the @react-pdf/renderer code inside a Next.js API route at /api/invoices/[id]/pdf. The route fetches invoice data from Supabase, creates a PDF document with React elements, renders it to a buffer, and returns it with Content-Type: application/pdf. This runs entirely server-side so there is no browser dependency.

### Why use advisory locks for invoice numbering?

Without advisory locks, two simultaneous invoice creations could read the same MAX(invoice_number) and generate duplicates. The pg_advisory_xact_lock function locks a unique key (combining user_id and year) for the duration of the transaction, ensuring only one process generates a number at a time. The lock automatically releases when the transaction commits.

### Can clients pay invoices without creating an account?

Yes. The payment page at /invoices/[id]/pay is public and requires no authentication. It displays the invoice details and a Pay Now button that redirects to the Stripe Payment Link. Stripe handles the entire checkout process. When payment completes, a Stripe webhook updates the invoice status to paid.

### How do generated columns work in the invoices table?

PostgreSQL generated columns automatically compute their values from other columns in the same row. The tax_amount column is defined as GENERATED ALWAYS AS (subtotal * tax_rate / 100) STORED, meaning it recalculates whenever subtotal or tax_rate changes. You cannot insert or update generated columns directly — the database manages them.

### Where should I store the Resend and Stripe API keys?

Store RESEND_API_KEY and STRIPE_SECRET_KEY in V0's Vars tab (the environment variables panel). Do not add the NEXT_PUBLIC_ prefix since these are server-side secrets used only in API routes and Server Actions. V0 injects them as process.env variables at runtime.

### How do I automatically mark invoices as overdue?

Create a Supabase scheduled function (pg_cron) that runs daily and updates invoices where status is 'sent' or 'viewed' and due_date is in the past. The SQL is: UPDATE invoices SET status = 'overdue' WHERE status IN ('sent', 'viewed') AND due_date < CURRENT_DATE. Enable pg_cron in the Supabase SQL editor.

### Can RapidDev help me customize this invoicing tool for my specific business?

Yes. RapidDev has helped over 600 businesses build custom invoicing and billing solutions. Whether you need multi-currency support, recurring invoice automation, or Stripe Connect for agency billing, you can book a free consultation at RapidDev to scope your project.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/client-invoicing-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/client-invoicing-tool
