Build an Automated Invoice Generator
Build an automated invoice generator with Next.js, PostgreSQL and a PDF renderer: templates, Stripe sync and email delivery on a VPS.
Build a service that generates branded, numbered invoices automatically from your product events and emails them as PDFs. Every helper referenced below is implemented below.
What we’re building
flowchart LR
Event[Product event webhook] --> API[Next.js API]
API --> PG[(PostgreSQL)]
API --> PDF[PDF renderer]
PDF --> Mail[Email via Resend]
API --> Mail
A webhook from your product (order, subscription, one-off payment) triggers: a race-free invoice number is claimed, the PDF is rendered from an HTML-ish template, it’s emailed to the customer, and the invoice row is recorded. Idempotent, auditable, and reproducible.
What you’ll learn
- Invoice numbering without race conditions or gaps (the classic hard part)
- Beautiful PDFs rendered from React components
- An idempotent, auditable resend/refund loop for a billing-adjacent system
Prerequisites
- Node.js 22+
- PostgreSQL 17
- A Resend account (or any SMTP provider with a Node SDK)
- A VPS with 1–2 GB RAM
1. Scaffold
npx create-next-app@latest invoices --ts --app
cd invoices
npm i @react-pdf/renderer pg resend zod
That matches the code: @react-pdf/renderer draws the PDF from React components, resend emails it, pg records it, zod validates the webhook payload.
2. Schema: numbering without races
The classic mistake is SELECT max(seq)+1 and then INSERT — two concurrent requests can read the same max and mint the same number. Instead, keep one counter row per year and let a single UPDATE ... RETURNING claim the next value atomically.
migrations/0001_init.sql:
CREATE TABLE invoice_counters (
year INTEGER PRIMARY KEY,
seq INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL, -- idempotency: one invoice per webhook event
number TEXT UNIQUE NOT NULL,
customer_email TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Apply: psql "$DATABASE_URL" -f migrations/0001_init.sql.
lib/numbering.ts:
import { pool } from './db';
// Atomically increments the counter for the year and returns "YYYY-0001".
export async function nextInvoiceNumber(year: number): Promise<string> {
const { rows } = await pool.query(
`INSERT INTO invoice_counters (year, seq) VALUES ($1, 1)
ON CONFLICT (year) DO UPDATE SET seq = invoice_counters.seq + 1
RETURNING seq`,
[year]
);
return `${year}-${String(rows[0].seq).padStart(4, '0')}`;
}
Why it’s safe: the ON CONFLICT upsert runs as one atomic statement. Two concurrent calls both hit the same row, PostgreSQL serializes them on the row’s lock, and each RETURNING gets a distinct seq. No gaps (a failed invoice still consumed its number — that’s fine and by design for accounting: numbers are issued, not fraud-avoided).
3. Database pool
lib/db.ts:
import { Pool } from 'pg';
const globalForPg = globalThis as unknown as { pool?: Pool };
export const pool =
globalForPg.pool ??
new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
if (process.env.NODE_ENV !== 'production') globalForPg.pool = pool;
4. Environment variables
.env (project root; not committed):
DATABASE_URL=postgres://postgres:dev@localhost:5432/invoices
RESEND_API_KEY=re_...
EMAIL_FROM="Billing <[email protected]>"
NEXT_PUBLIC_APP_URL=http://localhost:3000
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. |
RESEND_API_KEY |
yes | resend.com → API keys. This is the email service’s secret. |
EMAIL_FROM |
yes | The from address customers see. Must be a domain you own and verified in Resend (verify DNS: SPF/DKIM). |
NEXT_PUBLIC_APP_URL |
no | Base URL for PDF download links; http://localhost:3000 in dev, your domain in prod. |
5. PDF template
lib/pdf.tsx:
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: { padding: 40, fontSize: 11, fontFamily: 'Helvetica' },
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
});
// Render this component to a PDF buffer with the helper below.
export function InvoicePdf({ number, email, amountCents }: {
number: string;
email: string;
amountCents: number;
}) {
return (
<Document>
<Page size="A4" style={styles.page}>
<View>
<Text style={{ fontSize: 18, marginBottom: 12 }}>
{process.env.COMPANY_NAME ?? 'Your Company'} {/* set COMPANY_NAME in .env */}
</Text>
<View style={styles.row}>
<Text>Invoice #{number}</Text>
<Text>{new Date().toISOString().slice(0, 10)}</Text>
</View>
<View style={styles.row}>
<Text>Customer</Text>
<Text>{email}</Text>
</View>
<View style={styles.row}>
<Text>Amount</Text>
<Text>${(amountCents / 100).toFixed(2)}</Text>
</View>
</View>
</Page>
</Document>
);
}
import { pdf } from '@react-pdf/renderer';
export async function renderInvoicePdf(props: Parameters<typeof InvoicePdf>[0]) {
return await pdf(<InvoicePdf {...props} />).toBuffer();
}
- The template is a React component rendered to a real vector PDF — no flaky HTML-to-PDF DOM rendering, no font smuggling.
- Want the company name, currency or invoice line items changed? Set
COMPANY_NAME,CURRENCYor additems: Array<{ label, amountCents }>to the props and render a table — everything to touch is in this one file. - Fixed-width text and system fonts (
Helvetica) render identically everywhere — the fix for the classic “PDF looks right locally, wrong in prod” bug.
6. Email
lib/email.ts:
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendInvoice(email: string, number: string, pdfBuffer: Buffer) {
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: email,
subject: `Invoice ${number} for ${process.env.COMPANY_NAME ?? 'your subscription'}`,
attachments: [{ filename: `invoice-${number}.pdf`, content: pdfBuffer }],
reply_to: process.env.BILLING_REPLY_TO,
});
}
EMAIL_FROM and BILLING_REPLY_TO come from .env — the sending address is never hardcoded (a previous version of this guide shipped [email protected] baked in; that domain belongs to the guide author, not to you).
7. The automation hook
app/api/events/invoice/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { nextInvoiceNumber } from '@/lib/numbering';
import { renderInvoicePdf } from '@/lib/pdf';
import { sendInvoice } from '@/lib/email';
const payloadSchema = z.object({
eventId: z.string().min(1), // unique per product event (your checkout/subscription id)
email: z.string().email(),
amountCents: z.number().int().positive(), // e.g. 4900 = $49.00
});
export async function POST(req: NextRequest) {
let parsed;
try {
parsed = payloadSchema.safeParse(await req.json());
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { eventId, email, amountCents } = parsed.data;
try {
// Idempotency: a retried webhook must not mint a second invoice.
const existing = await pool.query(
'SELECT number FROM invoices WHERE event_id = $1', [eventId]
);
if (existing.rowCount) {
return NextResponse.json({ ok: true, number: existing.rows[0].number, reused: true });
}
const number = await nextInvoiceNumber(new Date().getFullYear());
const pdfBuffer = await renderInvoicePdf({ number, email, amountCents });
await sendInvoice(email, number, pdfBuffer);
// Record ONLY after the email succeeded — audit trail says "sent".
await pool.query(
`INSERT INTO invoices (event_id, number, customer_email, amount_cents, status)
VALUES ($1, $2, $3, $4, 'sent')`,
[eventId, number, email, amountCents]
);
return NextResponse.json({ ok: true, number });
} catch (err) {
console.error('invoice webhook failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 }); // triggers webhook retry
}
}
The flow, annotated:
- Validate first:
zodrejects malformed payloads before any money-adjacent work happens. - Idempotent: the
event_idlookup turns a Stripe-style redelivery into a no-op (reused: true) instead of a duplicate invoice + duplicate email. - Numbering is atomic: the counter upsert in
nextInvoiceNumberis the only place numbers are created — noSELECT maxraces. - Record after send: the invoice row is written after the email succeeds, so the audit table’s
status='sent'is always truthful. - Return 500 on failure so the webhook sender retries — a temporary downstream failure doesn’t silently swallow a bill.
8. Run locally
docker run -d --name pg --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17
export DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
psql "$DATABASE_URL" -f migrations/0001_init.sql
export $(grep -v '^#' .env | xargs)
npm run dev
Trigger a test invoice:
curl -s -X POST http://localhost:3000/api/events/invoice \
-H 'content-type: application/json' \
-d '{"eventId":"ord_123","email":"[email protected]","amountCents":4900}'
# {"ok":true,"number":"2026-0001"}
# repeat the same eventId -> no duplicate, same number back
curl -s -X POST http://localhost:3000/api/events/invoice \
-H 'content-type: application/json' \
-d '{"eventId":"ord_123","email":"[email protected]","amountCents":4900}'
# {"ok":true,"number":"2026-0001","reused":true}
In dev, Resend’s free plan doesn’t deliver outside your own address — use the test email resend verifies in your Resend dashboard, and check the Resend log to confirm the send.
9. Dockerize and deploy
Dockerfile (requires output: 'standalone' in next.config.ts):
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
On the VPS:
docker build -t invoices .
docker run -d --name inv --restart unless-stopped -p 3000:3000 \
-e DATABASE_URL=... -e RESEND_API_KEY=... \
-e EMAIL_FROM='Billing <[email protected]>' \
-e NEXT_PUBLIC_APP_URL=https://YOUR-DOMAIN.com invoices
Point your product’s webhook at https://YOUR-DOMAIN.com/api/events/invoice and confirm the signature header if your provider sends one (Stripe’s stripe-signature — verify it exactly like the build-saas guide’s webhook).
Cost (assumptions: 1,000 invoices/month)
| Service | Monthly cost |
|---|---|
| VPS 1 GB | $4.49 |
| Resend (3k emails free, then $20/50k) | $0 |
| Total | ≈ $5/month |
At 1k invoices you’re inside Resend’s free tier. At 50k invoices the cost is ~$20 for email plus the same $4.49 VPS.
Production checklist
- Webhook payload validated with zod (done) — billing bugs are expensive
- Idempotency verified (done:
event_idunique) — test the replay curl in step 8 - Webhook signature verification from your event provider
- Audit log of sends/resends (the
invoicestable is that log; addresent_atif you resend) - Numbering stress-tested: fire 20 concurrent requests, assert 20 distinct numbers
- SPF/DKIM verified in the Resend dashboard for
EMAIL_FROM’s domain - Daily
pg_dump— invoices are accounting data; test the restore
Common problems
- Duplicate invoice numbers: the sign you regressed to
SELECT max()+1— theON CONFLICTupsert is what protects you; never “fix” it with a mix-and-insert again. - PDF fonts broken in prod: use fixed-width layout and system fonts only (already the template’s default) — no external TTF fetching.
- Emails landing in spam: your domain needs SPF + DKIM in DNS and a verified sender in Resend; check the Resend dashboard’s delivery logs.
- Webhook redeliveries duplicate invoices: exactly what
event_id+ the early return prevents — keep thereusedflag in your logs to see how often your provider redelivers.
Improvements
- Recurring invoices (daily cron hits the same route with a new
eventIdper period) - PDF archive to object storage (
putObject-style helper, same shape as the screenshot guide) - Tax lines with country-specific logic (add
tax_cents, render it on the template) - A
/invoices/:id/pdfendpoint to re-download a PDF without re-emailing
Conclusion
Automated invoicing is a race-free numbering scheme, a React-to-PDF renderer and an email call behind an idempotent webhook. Five dollars a month replaces hours of manual billing — and every edge case that matters (races, replays, spam) is handled above.