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, CURRENCY or add items: 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: zod rejects malformed payloads before any money-adjacent work happens.
  • Idempotent: the event_id lookup 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 nextInvoiceNumber is the only place numbers are created — no SELECT max races.
  • 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_id unique) — test the replay curl in step 8
  • Webhook signature verification from your event provider
  • Audit log of sends/resends (the invoices table is that log; add resent_at if 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 — the ON CONFLICT upsert 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 the reused flag in your logs to see how often your provider redelivers.

Improvements

  • Recurring invoices (daily cron hits the same route with a new eventId per 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/pdf endpoint 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.

Related guides