Building JustInvoice: A Modern Invoicing App with Next.js 16 and Convex
I recently built JustInvoice, a free invoicing and billing application designed for freelancers and small businesses. It is now live and I wanted to share the technical journey of building it.
What is JustInvoice?
JustInvoice solves a simple problem: creating professional invoices shouldn’t require expensive software or complex workflows. Users can:
- Create and manage professional invoices with line items, discounts, and tax calculations
- Generate estimates (quotes) that can be converted to invoices
- Track payment status across multiple currencies
- Share secure links with clients who can view invoices without signing up
- Download branded PDFs
- Split estimates into multiple invoices for phased billing
The best part? It’s completely free.
Tech Stack
I chose a modern stack that prioritizes developer experience and performance:
| Category | Technology |
|---|---|
| Framework | Next.js 16.1.6 (App Router) |
| Language | TypeScript 5 |
| Styling | Tailwind CSS v4 |
| UI Components | shadcn/ui |
| Backend | Convex (serverless database + functions) |
| Authentication | Clerk |
| PDF Generation | @react-pdf/renderer |
| Analytics | PostHog |
Why Convex?
I chose Convex over traditional databases because it offers something unique: real-time synchronization with automatic optimistic updates. When a user creates an invoice, the dashboard updates instantly without waiting for the server response. If the mutation fails, it automatically rolls back.
Convex also handles:
- Automatic caching - Queries are cached and invalidated intelligently
- Type safety - Generated TypeScript types for all database operations
- File storage - Built-in support for logo uploads
- Scheduled functions - Perfect for tasks like sending overdue reminders
Here’s what a Convex query looks like:
// convex/dashboard.ts
export const getFinancialSummary = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
const invoices = await ctx.db
.query('invoices')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.filter((q) => q.neq(q.field('status'), 'draft'))
.collect();
// Aggregate across currencies with exchange rates
const currencyBreakdown = await aggregateByCurrency(ctx, invoices);
return {
outstanding: calculateOutstanding(currencyBreakdown),
overdue: calculateOverdue(currencyBreakdown),
paidThisMonth: calculatePaidThisMonth(invoices),
};
},
});
The Estimate Splitting Feature
One feature I’m particularly proud of is estimate splitting. Freelancers often work on projects billed in phases - 50% upfront, 50% on completion. Most invoicing software forces you to either create separate estimates or manually track partial conversions.
JustInvoice lets you split an estimate into multiple invoices:
// convex/estimates.ts
export const convertToInvoice = mutation({
args: {
estimateId: v.id('estimates'),
amount: v.number(), // Can be partial amount
},
handler: async (ctx, args) => {
const estimate = await ctx.db.get(args.estimateId);
const newConvertedAmount = estimate.convertedAmount + args.amount;
const remainingAmount = estimate.totalAmount - newConvertedAmount;
// Update estimate status based on conversion
const newStatus =
remainingAmount <= 0 ? 'converted' : 'partially_converted';
await ctx.db.patch(args.estimateId, {
convertedAmount: newConvertedAmount,
status: newStatus,
});
// Create invoice with the partial amount
const invoice = await createInvoiceFromEstimate(ctx, estimate, args.amount);
return invoice;
},
});
This maintains the relationship between the original estimate and all invoices created from it, giving users full visibility into their billing pipeline.
Secure Sharing Without Authentication
A key requirement was allowing clients to view invoices without creating accounts. Each invoice gets a unique 16-character share ID:
function generateShareId(): string {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from(
{ length: 16 },
() => chars[Math.floor(Math.random() * chars.length)],
).join('');
}
The public view route (/i/[shareId]) uses Convex’s unauthenticated queries
to fetch invoice data. This means:
- No Clerk session required
- Data is still protected (only valid share IDs work)
- Real-time updates if the invoice changes
Building the UI with shadcn/ui
I used shadcn/ui as the foundation for the component library. The beauty of shadcn is that the components live in your codebase - fully customizable without fighting against a design system.
The invoice form uses a combination of shadcn components:
export function InvoiceForm({ onSubmit, initialData }: InvoiceFormProps) {
const [lineItems, setLineItems] = useState<LineItem[]>(
initialData?.lineItems || [{ description: '', quantity: 1, rate: 0 }],
);
return (
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Invoice Details</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Invoice Number</Label>
<Input
name="invoiceNumber"
defaultValue={initialData?.invoiceNumber}
/>
</div>
<div>
<Label>Due Date</Label>
<DatePicker name="dueDate" defaultValue={initialData?.dueDate} />
</div>
</div>
<LineItemsTable items={lineItems} onChange={setLineItems} />
<InvoiceTotals
lineItems={lineItems}
discount={discount}
taxRate={taxRate}
/>
</CardContent>
</Card>
</form>
);
}
It creates a pixelated, animated background with interactive ripple effects when users hover or touch the screen. It’s subtle enough not to be distracting, but adds a level of polish that sets the landing page apart.
Multi-Currency Support
Supporting multiple currencies required careful handling of exchange rates. I store rates in Convex and update them periodically:
// convex/exchangeRates.ts
export const getExchangeRate = query({
args: { from: v.string(), to: v.string() },
handler: async (ctx, args) => {
// Check cache first
const cached = await ctx.db
.query('exchangeRates')
.withIndex('by_currency_pair', (q) =>
q.eq('fromCurrency', args.from).eq('toCurrency', args.to),
)
.first();
if (cached && isRateFresh(cached.updatedAt)) {
return cached.rate;
}
// Fetch fresh rate from API
return await fetchAndCacheRate(ctx, args.from, args.to);
},
});
The dashboard aggregates financial data across all currencies, converting to the user’s preferred currency for unified reporting.
PDF Generation
PDFs are generated client-side using @react-pdf/renderer. This avoids
serverless function cold starts and gives users instant downloads:
// components/InvoicePDF.tsx
export function InvoicePDF({ invoice, businessProfile }: InvoicePDFProps) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
{businessProfile.logoUrl && (
<Image src={businessProfile.logoUrl} style={styles.logo} />
)}
<Text style={styles.businessName}>{businessProfile.name}</Text>
</View>
<InvoiceDetails invoice={invoice} />
<LineItemsTable items={invoice.lineItems} />
<Totals
subtotal={invoice.subtotal}
discount={invoice.discount}
tax={invoice.tax}
total={invoice.total}
/>
<PaymentInstructions
instructions={businessProfile.paymentInstructions}
/>
</Page>
</Document>
);
}
Lessons Learned
Biome > ESLint + Prettier
I switched from ESLint + Prettier to Biome and haven’t looked back. It’s:
- Faster - Rust-based, instantaneous linting
- Simpler - One tool instead of two
- Stricter - Caught issues ESLint missed
{
"linter": {
"rules": {
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
},
"suspicious": {
"noExplicitAny": "error"
}
}
}
}
Type Safety at the Database Level
Convex’s schema validation catches data errors at the edge:
// convex/schema.ts
export default defineSchema({
invoices: defineTable({
userId: v.string(),
invoiceNumber: v.string(),
clientId: v.id('clients'),
lineItems: v.array(
v.object({
description: v.string(),
quantity: v.number(),
rate: v.number(),
}),
),
status: v.union(
v.literal('draft'),
v.literal('pending'),
v.literal('paid'),
v.literal('overdue'),
v.literal('cancelled'),
),
total: v.number(),
currency: v.string(),
shareId: v.string(),
})
.index('by_user', ['userId'])
.index('by_share_id', ['shareId']),
});
This means TypeScript knows exactly what shape your data is, everywhere in your app.
What’s Next?
I’m excited to continue building new features for JustInvoice and to see how it helps freelancers and small businesses. But for now, I’m happy to have a simple, free invoicing solution that anyone can use.
Try It Out
JustInvoice is live and completely free to use. If you’re a freelancer or small business owner looking for a simple, professional invoicing solution, give it a try.
I’m happy to fix any bugs or add new features if you need them. Just mail me
Built with Next.js, Convex, Clerk, and shadcn/ui.