Skip to main content
RapidDev - Software Development Agency

How to Build a Client Invoicing Tool with V0

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.

What you'll build

  • Invoice builder form with dynamic line items and auto-calculated subtotals, tax, and totals
  • Sequential invoice numbering system (INV-2026-0001) with race-condition-safe Supabase function
  • PDF invoice generation using @react-pdf/renderer with your business branding
  • Stripe Payment Links integration for client-facing payment pages
  • Client management with company details, contacts, and invoice history
  • Invoice status tracking from draft through sent, viewed, paid, and overdue
  • Email delivery of invoices with Resend API integration
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate18 min read1-2 hoursV0 Premium or Free tier, Supabase free tier, Stripe accountLast updated April 2026RapidDev Engineering Team
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.

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

Project overview

Tech stack

V0 by Vercel (AI code generation)
Next.js 14+ (App Router)
TypeScript
Tailwind CSS + shadcn/ui
Supabase (PostgreSQL + Auth)
@react-pdf/renderer (PDF generation)
Stripe (Payment Links)
Resend (email delivery)

Prerequisites

  • 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

Build steps

1

Set Up the Supabase Schema with Generated Columns

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.

typescript
1-- Create clients table
2create table clients (
3 id uuid primary key default gen_random_uuid(),
4 user_id uuid references auth.users not null,
5 company_name text not null,
6 contact_name text not null,
7 email text not null,
8 address jsonb default '{}',
9 created_at timestamptz default now()
10);
11
12-- Create invoices table with generated columns
13create table invoices (
14 id uuid primary key default gen_random_uuid(),
15 user_id uuid references auth.users not null,
16 client_id uuid references clients not null,
17 invoice_number text unique not null,
18 status text check (status in ('draft','sent','viewed','paid','overdue')) default 'draft',
19 issue_date date default current_date,
20 due_date date default (current_date + interval '30 days'),
21 subtotal numeric default 0,
22 tax_rate numeric default 0,
23 tax_amount numeric generated always as (subtotal * tax_rate / 100) stored,
24 total numeric generated always as (subtotal + subtotal * tax_rate / 100) stored,
25 notes text,
26 stripe_payment_link text,
27 paid_at timestamptz,
28 created_at timestamptz default now()
29);
30
31-- Create line items with generated amount
32create table line_items (
33 id uuid primary key default gen_random_uuid(),
34 invoice_id uuid references invoices on delete cascade not null,
35 description text not null,
36 quantity numeric not null default 1,
37 unit_price numeric not null default 0,
38 amount numeric generated always as (quantity * unit_price) stored
39);
40
41-- Business settings
42create table settings (
43 id uuid primary key default gen_random_uuid(),
44 user_id uuid references auth.users unique not null,
45 business_name text,
46 logo_url text,
47 address jsonb default '{}',
48 default_tax_rate numeric default 0,
49 payment_terms int default 30
50);
51
52-- Sequential invoice numbering function
53create or replace function next_invoice_number(p_user_id uuid)
54returns text
55language plpgsql
56as $$
57declare
58 v_year text := to_char(now(), 'YYYY');
59 v_max int;
60 v_next text;
61begin
62 perform pg_advisory_xact_lock(hashtext(p_user_id::text || v_year));
63
64 select coalesce(
65 max(cast(split_part(invoice_number, '-', 3) as int)), 0
66 ) into v_max
67 from invoices
68 where user_id = p_user_id
69 and invoice_number like 'INV-' || v_year || '-%';
70
71 v_next := 'INV-' || v_year || '-' || lpad((v_max + 1)::text, 4, '0');
72 return v_next;
73end;
74$$;
75
76-- Enable RLS
77alter table clients enable row level security;
78alter table invoices enable row level security;
79alter table line_items enable row level security;
80alter table settings enable row level security;
81
82create policy "Users manage own clients" on clients for all using (auth.uid() = user_id);
83create policy "Users manage own invoices" on invoices for all using (auth.uid() = user_id);
84create policy "Users manage own line items" on line_items for all
85 using (invoice_id in (select id from invoices where user_id = auth.uid()));
86create 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

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.

typescript
1// app/invoices/new/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import { createClient } from '@/lib/supabase/client';
7import { Button } from '@/components/ui/button';
8import { Input } from '@/components/ui/input';
9import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
11import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
12import { Textarea } from '@/components/ui/textarea';
13import { Trash2, Plus } from 'lucide-react';
14
15type LineItem = {
16 id: string;
17 description: string;
18 quantity: number;
19 unit_price: number;
20};
21
22export default function NewInvoicePage() {
23 const router = useRouter();
24 const supabase = createClient();
25 const [clientId, setClientId] = useState('');
26 const [taxRate, setTaxRate] = useState(0);
27 const [notes, setNotes] = useState('');
28 const [items, setItems] = useState<LineItem[]>([
29 { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }
30 ]);
31 const [saving, setSaving] = useState(false);
32
33 const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0);
34 const taxAmount = subtotal * taxRate / 100;
35 const total = subtotal + taxAmount;
36
37 function addItem() {
38 setItems([...items, { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }]);
39 }
40
41 function removeItem(id: string) {
42 if (items.length > 1) setItems(items.filter(i => i.id !== id));
43 }
44
45 function updateItem(id: string, field: keyof LineItem, value: string | number) {
46 setItems(items.map(i => i.id === id ? { ...i, [field]: value } : i));
47 }
48
49 async function handleSubmit() {
50 setSaving(true);
51 const { data: { user } } = await supabase.auth.getUser();
52 if (!user) return;
53
54 // Get next invoice number via RPC
55 const { data: invoiceNumber } = await supabase.rpc('next_invoice_number', {
56 p_user_id: user.id
57 });
58
59 // Create invoice
60 const { data: invoice, error } = await supabase.from('invoices').insert({
61 user_id: user.id,
62 client_id: clientId,
63 invoice_number: invoiceNumber,
64 subtotal,
65 tax_rate: taxRate,
66 notes
67 }).select().single();
68
69 if (error || !invoice) { setSaving(false); return; }
70
71 // Insert line items
72 await supabase.from('line_items').insert(
73 items.map(item => ({
74 invoice_id: invoice.id,
75 description: item.description,
76 quantity: item.quantity,
77 unit_price: item.unit_price
78 }))
79 );
80
81 router.push(`/invoices/${invoice.id}`);
82 }
83
84 return (
85 <div className="max-w-4xl mx-auto p-6">
86 <Card>
87 <CardHeader>
88 <CardTitle>New Invoice</CardTitle>
89 </CardHeader>
90 <CardContent className="space-y-6">
91 <div className="grid grid-cols-2 gap-4">
92 <Select value={clientId} onValueChange={setClientId}>
93 <SelectTrigger><SelectValue placeholder="Select client" /></SelectTrigger>
94 <SelectContent>
95 {/* Client options loaded from Supabase */}
96 </SelectContent>
97 </Select>
98 <Input type="number" placeholder="Tax rate %" value={taxRate}
99 onChange={e => setTaxRate(Number(e.target.value))} />
100 </div>
101
102 <Table>
103 <TableHeader>
104 <TableRow>
105 <TableHead className="w-[40%]">Description</TableHead>
106 <TableHead>Qty</TableHead>
107 <TableHead>Unit Price</TableHead>
108 <TableHead>Amount</TableHead>
109 <TableHead></TableHead>
110 </TableRow>
111 </TableHeader>
112 <TableBody>
113 {items.map(item => (
114 <TableRow key={item.id}>
115 <TableCell>
116 <Input value={item.description}
117 onChange={e => updateItem(item.id, 'description', e.target.value)} />
118 </TableCell>
119 <TableCell>
120 <Input type="number" className="w-20" value={item.quantity}
121 onChange={e => updateItem(item.id, 'quantity', Number(e.target.value))} />
122 </TableCell>
123 <TableCell>
124 <Input type="number" className="w-28" value={item.unit_price}
125 onChange={e => updateItem(item.id, 'unit_price', Number(e.target.value))} />
126 </TableCell>
127 <TableCell className="font-medium">
128 ${(item.quantity * item.unit_price).toFixed(2)}
129 </TableCell>
130 <TableCell>
131 <Button variant="ghost" size="icon" onClick={() => removeItem(item.id)}>
132 <Trash2 className="h-4 w-4" />
133 </Button>
134 </TableCell>
135 </TableRow>
136 ))}
137 </TableBody>
138 </Table>
139
140 <Button variant="outline" onClick={addItem}>
141 <Plus className="h-4 w-4 mr-2" /> Add Line Item
142 </Button>
143
144 <Textarea placeholder="Notes (optional)" value={notes}
145 onChange={e => setNotes(e.target.value)} />
146 </CardContent>
147 <CardFooter className="flex flex-col items-end gap-1">
148 <div className="text-sm text-muted-foreground">Subtotal: ${subtotal.toFixed(2)}</div>
149 <div className="text-sm text-muted-foreground">Tax ({taxRate}%): ${taxAmount.toFixed(2)}</div>
150 <div className="text-lg font-bold">Total: ${total.toFixed(2)}</div>
151 <Button onClick={handleSubmit} disabled={saving} className="mt-4">
152 {saving ? 'Creating...' : 'Create Invoice'}
153 </Button>
154 </CardFooter>
155 </Card>
156 </div>
157 );
158}

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

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.

typescript
1// app/api/invoices/[id]/pdf/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { createClient } from '@/lib/supabase/server';
4import { renderToBuffer } from '@react-pdf/renderer';
5import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
6import React from 'react';
7
8const styles = StyleSheet.create({
9 page: { padding: 40, fontFamily: 'Helvetica', fontSize: 10 },
10 header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 30 },
11 title: { fontSize: 24, fontWeight: 'bold', color: '#111827' },
12 invoiceNumber: { fontSize: 12, color: '#6B7280', marginTop: 4 },
13 section: { marginBottom: 20 },
14 label: { fontSize: 8, color: '#6B7280', textTransform: 'uppercase', marginBottom: 4 },
15 tableHeader: { flexDirection: 'row', borderBottomWidth: 1, borderColor: '#E5E7EB', paddingBottom: 6, marginBottom: 8 },
16 tableRow: { flexDirection: 'row', paddingVertical: 4 },
17 colDesc: { flex: 3 },
18 colQty: { flex: 1, textAlign: 'center' },
19 colPrice: { flex: 1, textAlign: 'right' },
20 colAmount: { flex: 1, textAlign: 'right' },
21 totals: { marginTop: 20, alignItems: 'flex-end' },
22 totalRow: { flexDirection: 'row', width: 200, justifyContent: 'space-between', paddingVertical: 2 },
23 grandTotal: { fontSize: 14, fontWeight: 'bold' },
24});
25
26export async function GET(
27 request: NextRequest,
28 { params }: { params: Promise<{ id: string }> }
29) {
30 const { id } = await params;
31 const supabase = await createClient();
32
33 const { data: invoice } = await supabase
34 .from('invoices')
35 .select('*, clients(*), line_items(*)')
36 .eq('id', id)
37 .single();
38
39 if (!invoice) {
40 return NextResponse.json({ error: 'Not found' }, { status: 404 });
41 }
42
43 const doc = React.createElement(Document, {},
44 React.createElement(Page, { size: 'A4', style: styles.page },
45 React.createElement(View, { style: styles.header },
46 React.createElement(View, {},
47 React.createElement(Text, { style: styles.title }, 'INVOICE'),
48 React.createElement(Text, { style: styles.invoiceNumber }, invoice.invoice_number)
49 ),
50 React.createElement(View, { style: { alignItems: 'flex-end' } },
51 React.createElement(Text, {}, `Issue: ${invoice.issue_date}`),
52 React.createElement(Text, {}, `Due: ${invoice.due_date}`)
53 )
54 ),
55 React.createElement(View, { style: styles.section },
56 React.createElement(Text, { style: styles.label }, 'Bill To'),
57 React.createElement(Text, {}, invoice.clients.company_name),
58 React.createElement(Text, {}, invoice.clients.contact_name),
59 React.createElement(Text, {}, invoice.clients.email)
60 ),
61 React.createElement(View, { style: styles.tableHeader },
62 React.createElement(Text, { style: styles.colDesc }, 'Description'),
63 React.createElement(Text, { style: styles.colQty }, 'Qty'),
64 React.createElement(Text, { style: styles.colPrice }, 'Price'),
65 React.createElement(Text, { style: styles.colAmount }, 'Amount')
66 ),
67 ...invoice.line_items.map((item: any) =>
68 React.createElement(View, { key: item.id, style: styles.tableRow },
69 React.createElement(Text, { style: styles.colDesc }, item.description),
70 React.createElement(Text, { style: styles.colQty }, String(item.quantity)),
71 React.createElement(Text, { style: styles.colPrice }, `$${item.unit_price.toFixed(2)}`),
72 React.createElement(Text, { style: styles.colAmount }, `$${item.amount.toFixed(2)}`)
73 )
74 ),
75 React.createElement(View, { style: styles.totals },
76 React.createElement(View, { style: styles.totalRow },
77 React.createElement(Text, {}, 'Subtotal'),
78 React.createElement(Text, {}, `$${invoice.subtotal.toFixed(2)}`)
79 ),
80 React.createElement(View, { style: styles.totalRow },
81 React.createElement(Text, {}, `Tax (${invoice.tax_rate}%)`),
82 React.createElement(Text, {}, `$${invoice.tax_amount.toFixed(2)}`)
83 ),
84 React.createElement(View, { style: styles.totalRow },
85 React.createElement(Text, { style: styles.grandTotal }, 'Total'),
86 React.createElement(Text, { style: styles.grandTotal }, `$${invoice.total.toFixed(2)}`)
87 )
88 )
89 )
90 );
91
92 const buffer = await renderToBuffer(doc);
93
94 return new NextResponse(buffer, {
95 headers: {
96 'Content-Type': 'application/pdf',
97 'Content-Disposition': `inline; filename="${invoice.invoice_number}.pdf"`,
98 },
99 });
100}

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

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.

typescript
1// app/invoices/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { Badge } from '@/components/ui/badge';
4import { Button } from '@/components/ui/button';
5import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
7import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
8import Link from 'next/link';
9import { Plus, Download, Send } from 'lucide-react';
10
11const statusColors: Record<string, string> = {
12 draft: 'bg-gray-100 text-gray-800',
13 sent: 'bg-blue-100 text-blue-800',
14 viewed: 'bg-yellow-100 text-yellow-800',
15 paid: 'bg-green-100 text-green-800',
16 overdue: 'bg-red-100 text-red-800',
17};
18
19export default async function InvoicesPage() {
20 const supabase = await createClient();
21 const { data: invoices } = await supabase
22 .from('invoices')
23 .select('*, clients(company_name, contact_name)')
24 .order('created_at', { ascending: false });
25
26 const allInvoices = invoices ?? [];
27 const totalOutstanding = allInvoices
28 .filter(inv => ['sent', 'viewed', 'overdue'].includes(inv.status))
29 .reduce((sum, inv) => sum + Number(inv.total), 0);
30 const totalPaid = allInvoices
31 .filter(inv => inv.status === 'paid')
32 .reduce((sum, inv) => sum + Number(inv.total), 0);
33
34 function renderTable(filtered: typeof allInvoices) {
35 return (
36 <Table>
37 <TableHeader>
38 <TableRow>
39 <TableHead>Invoice</TableHead>
40 <TableHead>Client</TableHead>
41 <TableHead>Status</TableHead>
42 <TableHead>Issue Date</TableHead>
43 <TableHead>Due Date</TableHead>
44 <TableHead className="text-right">Total</TableHead>
45 <TableHead></TableHead>
46 </TableRow>
47 </TableHeader>
48 <TableBody>
49 {filtered.map(invoice => (
50 <TableRow key={invoice.id}>
51 <TableCell className="font-medium">
52 <Link href={`/invoices/${invoice.id}`} className="hover:underline">
53 {invoice.invoice_number}
54 </Link>
55 </TableCell>
56 <TableCell>{invoice.clients?.company_name}</TableCell>
57 <TableCell>
58 <Badge className={statusColors[invoice.status]}>
59 {invoice.status}
60 </Badge>
61 </TableCell>
62 <TableCell>{invoice.issue_date}</TableCell>
63 <TableCell>{invoice.due_date}</TableCell>
64 <TableCell className="text-right font-medium">
65 ${Number(invoice.total).toFixed(2)}
66 </TableCell>
67 <TableCell>
68 <div className="flex gap-1">
69 <Button variant="ghost" size="icon" asChild>
70 <a href={`/api/invoices/${invoice.id}/pdf`}><Download className="h-4 w-4" /></a>
71 </Button>
72 </div>
73 </TableCell>
74 </TableRow>
75 ))}
76 </TableBody>
77 </Table>
78 );
79 }
80
81 return (
82 <div className="max-w-6xl mx-auto p-6 space-y-6">
83 <div className="flex items-center justify-between">
84 <h1 className="text-3xl font-bold">Invoices</h1>
85 <Button asChild><Link href="/invoices/new"><Plus className="h-4 w-4 mr-2" /> New Invoice</Link></Button>
86 </div>
87
88 <div className="grid grid-cols-3 gap-4">
89 <Card>
90 <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Outstanding</CardTitle></CardHeader>
91 <CardContent><p className="text-2xl font-bold">${totalOutstanding.toFixed(2)}</p></CardContent>
92 </Card>
93 <Card>
94 <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Paid</CardTitle></CardHeader>
95 <CardContent><p className="text-2xl font-bold text-green-600">${totalPaid.toFixed(2)}</p></CardContent>
96 </Card>
97 <Card>
98 <CardHeader className="pb-2"><CardTitle className="text-sm text-muted-foreground">Total Invoices</CardTitle></CardHeader>
99 <CardContent><p className="text-2xl font-bold">{allInvoices.length}</p></CardContent>
100 </Card>
101 </div>
102
103 <Tabs defaultValue="all">
104 <TabsList>
105 <TabsTrigger value="all">All ({allInvoices.length})</TabsTrigger>
106 <TabsTrigger value="draft">Draft</TabsTrigger>
107 <TabsTrigger value="sent">Sent</TabsTrigger>
108 <TabsTrigger value="paid">Paid</TabsTrigger>
109 <TabsTrigger value="overdue">Overdue</TabsTrigger>
110 </TabsList>
111 <TabsContent value="all">{renderTable(allInvoices)}</TabsContent>
112 <TabsContent value="draft">{renderTable(allInvoices.filter(i => i.status === 'draft'))}</TabsContent>
113 <TabsContent value="sent">{renderTable(allInvoices.filter(i => i.status === 'sent'))}</TabsContent>
114 <TabsContent value="paid">{renderTable(allInvoices.filter(i => i.status === 'paid'))}</TabsContent>
115 <TabsContent value="overdue">{renderTable(allInvoices.filter(i => i.status === 'overdue'))}</TabsContent>
116 </Tabs>
117 </div>
118 );
119}

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

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.

typescript
1// app/invoices/[id]/pay/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { Badge } from '@/components/ui/badge';
4import { Button } from '@/components/ui/button';
5import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
6import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
7import { CheckCircle } from 'lucide-react';
8
9export default async function PaymentPage({
10 params,
11}: {
12 params: Promise<{ id: string }>;
13}) {
14 const { id } = await params;
15 const supabase = await createClient();
16
17 const { data: invoice } = await supabase
18 .from('invoices')
19 .select('*, clients(company_name), line_items(*)')
20 .eq('id', id)
21 .single();
22
23 if (!invoice) {
24 return <div className="p-12 text-center">Invoice not found.</div>;
25 }
26
27 // Mark as viewed if it was sent
28 if (invoice.status === 'sent') {
29 await supabase
30 .from('invoices')
31 .update({ status: 'viewed' })
32 .eq('id', id);
33 }
34
35 if (invoice.status === 'paid') {
36 return (
37 <div className="max-w-2xl mx-auto p-12 text-center space-y-4">
38 <CheckCircle className="h-16 w-16 text-green-500 mx-auto" />
39 <h1 className="text-2xl font-bold">Payment Received</h1>
40 <p className="text-muted-foreground">
41 Invoice {invoice.invoice_number} was paid on{' '}
42 {new Date(invoice.paid_at).toLocaleDateString()}.
43 </p>
44 </div>
45 );
46 }
47
48 return (
49 <div className="max-w-2xl mx-auto p-6">
50 <Card>
51 <CardHeader>
52 <div className="flex items-center justify-between">
53 <CardTitle>{invoice.invoice_number}</CardTitle>
54 <Badge>{invoice.status}</Badge>
55 </div>
56 <p className="text-sm text-muted-foreground">
57 Due {new Date(invoice.due_date).toLocaleDateString()}
58 </p>
59 </CardHeader>
60 <CardContent>
61 <Table>
62 <TableHeader>
63 <TableRow>
64 <TableHead>Description</TableHead>
65 <TableHead className="text-center">Qty</TableHead>
66 <TableHead className="text-right">Price</TableHead>
67 <TableHead className="text-right">Amount</TableHead>
68 </TableRow>
69 </TableHeader>
70 <TableBody>
71 {invoice.line_items.map((item: any) => (
72 <TableRow key={item.id}>
73 <TableCell>{item.description}</TableCell>
74 <TableCell className="text-center">{item.quantity}</TableCell>
75 <TableCell className="text-right">${item.unit_price.toFixed(2)}</TableCell>
76 <TableCell className="text-right">${item.amount.toFixed(2)}</TableCell>
77 </TableRow>
78 ))}
79 </TableBody>
80 </Table>
81 <div className="mt-4 flex flex-col items-end gap-1 text-sm">
82 <span>Subtotal: ${Number(invoice.subtotal).toFixed(2)}</span>
83 <span>Tax ({invoice.tax_rate}%): ${Number(invoice.tax_amount).toFixed(2)}</span>
84 <span className="text-lg font-bold">Total: ${Number(invoice.total).toFixed(2)}</span>
85 </div>
86 </CardContent>
87 <CardFooter>
88 {invoice.stripe_payment_link ? (
89 <Button asChild className="w-full" size="lg">
90 <a href={invoice.stripe_payment_link}>Pay ${Number(invoice.total).toFixed(2)} Now</a>
91 </Button>
92 ) : (
93 <p className="text-sm text-muted-foreground">Contact sender for payment instructions.</p>
94 )}
95 </CardFooter>
96 </Card>
97 </div>
98 );
99}

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

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.

typescript
1// app/actions/send-invoice.ts
2'use server';
3
4import { createClient } from '@/lib/supabase/server';
5import { Resend } from 'resend';
6
7const resend = new Resend(process.env.RESEND_API_KEY);
8
9export async function sendInvoice(invoiceId: string) {
10 const supabase = await createClient();
11
12 const { data: invoice } = await supabase
13 .from('invoices')
14 .select('*, clients(email, contact_name, company_name)')
15 .eq('id', invoiceId)
16 .single();
17
18 if (!invoice) throw new Error('Invoice not found');
19
20 const paymentUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invoices/${invoiceId}/pay`;
21
22 // Fetch business settings for branding
23 const { data: settings } = await supabase
24 .from('settings')
25 .select('*')
26 .eq('user_id', invoice.user_id)
27 .single();
28
29 await resend.emails.send({
30 from: `${settings?.business_name || 'Invoice'} <invoices@yourdomain.com>`,
31 to: invoice.clients.email,
32 subject: `Invoice ${invoice.invoice_number} - $${Number(invoice.total).toFixed(2)}`,
33 html: `
34 <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
35 <h2>Invoice ${invoice.invoice_number}</h2>
36 <p>Hi ${invoice.clients.contact_name},</p>
37 <p>You have a new invoice from ${settings?.business_name || 'us'} for <strong>$${Number(invoice.total).toFixed(2)}</strong>.</p>
38 <p>Due date: ${new Date(invoice.due_date).toLocaleDateString()}</p>
39 <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>
40 <p style="margin-top: 24px; color: #6B7280; font-size: 14px;">If you have questions about this invoice, reply to this email.</p>
41 </div>
42 `,
43 });
44
45 // Update status to sent
46 await supabase
47 .from('invoices')
48 .update({ status: 'sent' })
49 .eq('id', invoiceId);
50
51 return { success: true };
52}

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

app/invoices/new/page.tsx
1'use client';
2
3import { useState, useEffect } from 'react';
4import { useRouter } from 'next/navigation';
5import { createClient } from '@/lib/supabase/client';
6import { Button } from '@/components/ui/button';
7import { Input } from '@/components/ui/input';
8import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
10import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
11import { Textarea } from '@/components/ui/textarea';
12import { Trash2, Plus } from 'lucide-react';
13
14type LineItem = { id: string; description: string; quantity: number; unit_price: number };
15type Client = { id: string; company_name: string; contact_name: string };
16
17export default function NewInvoicePage() {
18 const router = useRouter();
19 const supabase = createClient();
20 const [clients, setClients] = useState<Client[]>([]);
21 const [clientId, setClientId] = useState('');
22 const [taxRate, setTaxRate] = useState(0);
23 const [notes, setNotes] = useState('');
24 const [saving, setSaving] = useState(false);
25 const [items, setItems] = useState<LineItem[]>([
26 { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 },
27 ]);
28
29 useEffect(() => {
30 supabase.from('clients').select('id, company_name, contact_name').then(({ data }) => {
31 if (data) setClients(data);
32 });
33 supabase.from('settings').select('default_tax_rate').limit(1).single().then(({ data }) => {
34 if (data?.default_tax_rate) setTaxRate(data.default_tax_rate);
35 });
36 }, []);
37
38 const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unit_price, 0);
39 const taxAmount = subtotal * taxRate / 100;
40 const total = subtotal + taxAmount;
41
42 function addItem() {
43 setItems(prev => [...prev, { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }]);
44 }
45 function removeItem(id: string) {
46 if (items.length > 1) setItems(prev => prev.filter(i => i.id !== id));
47 }
48 function updateItem(id: string, field: keyof LineItem, value: string | number) {
49 setItems(prev => prev.map(i => (i.id === id ? { ...i, [field]: value } : i)));
50 }
51
52 async function handleSubmit() {
53 if (!clientId || items.some(i => !i.description)) return;
54 setSaving(true);
55 const { data: { user } } = await supabase.auth.getUser();
56 if (!user) { setSaving(false); return; }
57
58 const { data: invoiceNumber } = await supabase.rpc('next_invoice_number', { p_user_id: user.id });
59 const { data: invoice, error } = await supabase.from('invoices').insert({
60 user_id: user.id, client_id: clientId, invoice_number: invoiceNumber,
61 subtotal, tax_rate: taxRate, notes,
62 }).select().single();
63
64 if (error || !invoice) { setSaving(false); return; }
65
66 await supabase.from('line_items').insert(
67 items.map(i => ({ invoice_id: invoice.id, description: i.description, quantity: i.quantity, unit_price: i.unit_price }))
68 );
69 router.push(`/invoices/${invoice.id}`);
70 }
71
72 return (
73 <div className="max-w-4xl mx-auto p-6">
74 <Card>
75 <CardHeader><CardTitle>New Invoice</CardTitle></CardHeader>
76 <CardContent className="space-y-6">
77 <div className="grid grid-cols-2 gap-4">
78 <Select value={clientId} onValueChange={setClientId}>
79 <SelectTrigger><SelectValue placeholder="Select client" /></SelectTrigger>
80 <SelectContent>
81 {clients.map(c => (
82 <SelectItem key={c.id} value={c.id}>{c.company_name} {c.contact_name}</SelectItem>
83 ))}
84 </SelectContent>
85 </Select>
86 <Input type="number" placeholder="Tax rate %" value={taxRate} onChange={e => setTaxRate(Number(e.target.value))} />
87 </div>
88 <Table>
89 <TableHeader>
90 <TableRow>
91 <TableHead className="w-[40%]">Description</TableHead>
92 <TableHead>Qty</TableHead>
93 <TableHead>Unit Price</TableHead>
94 <TableHead>Amount</TableHead>
95 <TableHead />
96 </TableRow>
97 </TableHeader>
98 <TableBody>
99 {items.map(item => (
100 <TableRow key={item.id}>
101 <TableCell><Input value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} placeholder="Service description" /></TableCell>
102 <TableCell><Input type="number" className="w-20" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', Number(e.target.value))} /></TableCell>
103 <TableCell><Input type="number" className="w-28" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', Number(e.target.value))} /></TableCell>
104 <TableCell className="font-medium">${(item.quantity * item.unit_price).toFixed(2)}</TableCell>
105 <TableCell><Button variant="ghost" size="icon" onClick={() => removeItem(item.id)}><Trash2 className="h-4 w-4" /></Button></TableCell>
106 </TableRow>
107 ))}
108 </TableBody>
109 </Table>
110 <Button variant="outline" onClick={addItem}><Plus className="h-4 w-4 mr-2" /> Add Line Item</Button>
111 <Textarea placeholder="Notes for the client (optional)" value={notes} onChange={e => setNotes(e.target.value)} />
112 </CardContent>
113 <CardFooter className="flex flex-col items-end gap-1">
114 <span className="text-sm text-muted-foreground">Subtotal: ${subtotal.toFixed(2)}</span>
115 <span className="text-sm text-muted-foreground">Tax ({taxRate}%): ${taxAmount.toFixed(2)}</span>
116 <span className="text-lg font-bold">Total: ${total.toFixed(2)}</span>
117 <Button onClick={handleSubmit} disabled={saving || !clientId} className="mt-4">{saving ? 'Creating...' : 'Create Invoice'}</Button>
118 </CardFooter>
119 </Card>
120 </div>
121 );
122}

Customization ideas

Common pitfalls

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I need a Next.js App Router invoice builder page with dynamic line items (add/remove rows), auto-calculated subtotals and tax, client selector dropdown, and date pickers. Each line item has description, quantity, and unit_price. Use shadcn/ui Table, Input, Select, and Button components with TypeScript.

Build Prompt

Create a Supabase function called next_invoice_number that uses pg_advisory_xact_lock to safely generate sequential invoice numbers in the format INV-YYYY-NNNN. Then build a Next.js API route at /api/invoices/[id]/pdf that uses @react-pdf/renderer to generate a PDF with line items table, client details, and calculated totals.

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.

RapidDev

Talk to an Expert

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

Book a free consultation

Want this built for you?

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

Get a fixed-price quote

We put the rapid in RapidDev

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