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.
| Fact | Value |
|---|---|
| Tool | V0 |
| Build time | 1-2 hours |
| Difficulty | Intermediate |
| Last updated | April 2026 |
Project overview
Tech stack
Prerequisites
- A V0 account (free or Premium)
- A Supabase project with 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
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.
1-- Create clients table2create 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);1112-- Create invoices table with generated columns13create 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);3031-- Create line items with generated amount32create 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) stored39);4041-- Business settings42create 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 3050);5152-- Sequential invoice numbering function53create or replace function next_invoice_number(p_user_id uuid)54returns text55language plpgsql56as $$57declare58 v_year text := to_char(now(), 'YYYY');59 v_max int;60 v_next text;61begin62 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)), 066 ) into v_max67 from invoices68 where user_id = p_user_id69 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$$;7576-- Enable RLS77alter 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;8182create 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 all85 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.
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.
1// app/invoices/new/page.tsx2'use client';34import { 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';1415type LineItem = {16 id: string;17 description: string;18 quantity: number;19 unit_price: number;20};2122export 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);3233 const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0);34 const taxAmount = subtotal * taxRate / 100;35 const total = subtotal + taxAmount;3637 function addItem() {38 setItems([...items, { id: crypto.randomUUID(), description: '', quantity: 1, unit_price: 0 }]);39 }4041 function removeItem(id: string) {42 if (items.length > 1) setItems(items.filter(i => i.id !== id));43 }4445 function updateItem(id: string, field: keyof LineItem, value: string | number) {46 setItems(items.map(i => i.id === id ? { ...i, [field]: value } : i));47 }4849 async function handleSubmit() {50 setSaving(true);51 const { data: { user } } = await supabase.auth.getUser();52 if (!user) return;5354 // Get next invoice number via RPC55 const { data: invoiceNumber } = await supabase.rpc('next_invoice_number', {56 p_user_id: user.id57 });5859 // Create invoice60 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 notes67 }).select().single();6869 if (error || !invoice) { setSaving(false); return; }7071 // Insert line items72 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_price78 }))79 );8081 router.push(`/invoices/${invoice.id}`);82 }8384 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>101102 <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>139140 <Button variant="outline" onClick={addItem}>141 <Plus className="h-4 w-4 mr-2" /> Add Line Item142 </Button>143144 <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.
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.
1// app/api/invoices/[id]/pdf/route.ts2import { 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';78const 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});2526export async function GET(27 request: NextRequest,28 { params }: { params: Promise<{ id: string }> }29) {30 const { id } = await params;31 const supabase = await createClient();3233 const { data: invoice } = await supabase34 .from('invoices')35 .select('*, clients(*), line_items(*)')36 .eq('id', id)37 .single();3839 if (!invoice) {40 return NextResponse.json({ error: 'Not found' }, { status: 404 });41 }4243 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 );9192 const buffer = await renderToBuffer(doc);9394 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.
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.
1// app/invoices/page.tsx2import { 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';1011const 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};1819export default async function InvoicesPage() {20 const supabase = await createClient();21 const { data: invoices } = await supabase22 .from('invoices')23 .select('*, clients(company_name, contact_name)')24 .order('created_at', { ascending: false });2526 const allInvoices = invoices ?? [];27 const totalOutstanding = allInvoices28 .filter(inv => ['sent', 'viewed', 'overdue'].includes(inv.status))29 .reduce((sum, inv) => sum + Number(inv.total), 0);30 const totalPaid = allInvoices31 .filter(inv => inv.status === 'paid')32 .reduce((sum, inv) => sum + Number(inv.total), 0);3334 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 }8081 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>8788 <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>102103 <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.
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.
1// app/invoices/[id]/pay/page.tsx2import { 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';89export default async function PaymentPage({10 params,11}: {12 params: Promise<{ id: string }>;13}) {14 const { id } = await params;15 const supabase = await createClient();1617 const { data: invoice } = await supabase18 .from('invoices')19 .select('*, clients(company_name), line_items(*)')20 .eq('id', id)21 .single();2223 if (!invoice) {24 return <div className="p-12 text-center">Invoice not found.</div>;25 }2627 // Mark as viewed if it was sent28 if (invoice.status === 'sent') {29 await supabase30 .from('invoices')31 .update({ status: 'viewed' })32 .eq('id', id);33 }3435 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 }4748 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.
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.
1// app/actions/send-invoice.ts2'use server';34import { createClient } from '@/lib/supabase/server';5import { Resend } from 'resend';67const resend = new Resend(process.env.RESEND_API_KEY);89export async function sendInvoice(invoiceId: string) {10 const supabase = await createClient();1112 const { data: invoice } = await supabase13 .from('invoices')14 .select('*, clients(email, contact_name, company_name)')15 .eq('id', invoiceId)16 .single();1718 if (!invoice) throw new Error('Invoice not found');1920 const paymentUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invoices/${invoiceId}/pay`;2122 // Fetch business settings for branding23 const { data: settings } = await supabase24 .from('settings')25 .select('*')26 .eq('user_id', invoice.user_id)27 .single();2829 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 });4445 // Update status to sent46 await supabase47 .from('invoices')48 .update({ status: 'sent' })49 .eq('id', invoiceId);5051 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
1'use client';23import { 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';1314type LineItem = { id: string; description: string; quantity: number; unit_price: number };15type Client = { id: string; company_name: string; contact_name: string };1617export 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 ]);2829 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 }, []);3738 const subtotal = items.reduce((sum, i) => sum + i.quantity * i.unit_price, 0);39 const taxAmount = subtotal * taxRate / 100;40 const total = subtotal + taxAmount;4142 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 }5152 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; }5758 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();6364 if (error || !invoice) { setSaving(false); return; }6566 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 }7172 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation