PDF Invoices
Generate a branded PDF invoice in Next.js the moment Stripe fires invoice.paid: render with React-PDF inside an Inngest step, store it in a private Supabase bucket, email it through Resend, and serve billing history behind signed URLs.
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
A customer pays, and 20 seconds later a branded PDF receipt lands in their inbox and shows up in their billing history. No cron job scanning for new payments, no PDF service, no headless Chrome. This guide builds that pipeline in Next.js 16: React-PDF renders the document inside an Inngest step, Supabase Storage holds it in a private bucket, Resend attaches it to the email, and signed URLs hand it back on demand.
What Runs When
The chain has one entry point and four steps. Stripe fires invoice.paid. Your webhook verifies the signature, resolves the Stripe customer to one of your users, sends a single Inngest event, and returns. Everything expensive happens after that, off the request path.
Inside the Inngest function, step one pulls the invoice detail from Stripe. Step two renders the PDF and uploads it to a private bucket. Step three writes a row to your invoices table with the storage path. Step four downloads the file back out and emails it through Resend. Each step is a durable checkpoint, so a Resend outage re-runs only the email, never the render.
That ordering is deliberate. Storage comes before the email because the file is the source of truth and the email is a courtesy copy. If you email first and the upload fails, the customer has a receipt your app cannot show them, which is the worst version of this feature. Writing the ledger row before the email also means billing history is correct even if the customer's mail provider bounces every message you send.
Claude Code will happily wire this up in one shot if you tell it the shape first. A short brief in CLAUDE.md keeps it from reaching for Puppeteer or making the bucket public:
## Invoice PDFs
- Trigger: Stripe invoice.paid webhook -> inngest.send -> generate-invoice-pdf.
- Webhook does signature verification and nothing else. No rendering in the route.
- Rendering uses @react-pdf/renderer renderToBuffer. Never Puppeteer or Chromium.
- Bucket "invoices" is PRIVATE. Files live at {userId}/{invoiceNumber}.pdf.
- Buffers never cross an Inngest step boundary. Store first, re-download later.
- Downloads are signed URLs (60s), minted only after an RLS-checked ownership read.The Bucket and the Ledger
Two pieces of state. A private storage bucket for the bytes, and a table that records what was issued so billing history does not depend on listing files.
The table stores the Stripe invoice id with a unique constraint, which is what makes the whole pipeline idempotent. If Stripe redelivers the event, the upsert lands on the same row and the file is overwritten with identical content. Row level security lets a user read only their own invoices, and there is deliberately no insert or update policy: writes come from the service role inside the Inngest function.
-- supabase/migrations/20260730_invoices.sql
create table public.invoices (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
stripe_invoice_id text not null unique,
number text not null,
currency text not null,
amount_paid_cents integer not null,
pdf_path text,
issued_at timestamptz not null,
created_at timestamptz not null default now()
);
create index invoices_user_issued_idx
on public.invoices (user_id, issued_at desc);
alter table public.invoices enable row level security;
create policy "users read own invoices"
on public.invoices for select
to authenticated
using (auth.uid() = user_id);
insert into storage.buckets (id, name, public)
values ('invoices', 'invoices', false)
on conflict (id) do nothing;
create policy "users read own invoice files"
on storage.objects for select
to authenticated
using (
bucket_id = 'invoices'
and (storage.foldername(name))[1] = auth.uid()::text
);The storage path is worth a second of thought before you write any code, because changing it later means migrating files. Putting the owner's user id first ({userId}/{invoiceNumber}.pdf) is what makes the storage policy above a one-line check: Supabase exposes storage.foldername(name) so the policy can compare the first path segment to auth.uid(). A flat namespace of invoice numbers would force a join against your table on every read, which storage policies cannot do cheaply.
The pipeline needs a Supabase client that bypasses RLS, because the Inngest function has no user session. Keep it in its own module so nothing in the client bundle can ever import it by accident.
// lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);The Invoice Document
React-PDF is a React renderer that outputs PDF instead of DOM. You write components, it produces bytes. Version 4 supports React 19, and Next.js already opts the package out of server bundling, so no next.config.ts change is needed in most projects.
Install it alongside the rest of the pipeline:
npm install @react-pdf/renderer inngest resend stripe @supabase/supabase-js
npm install --save-dev inngest-cliThe document is a plain component tree. Document wraps Page, and inside you use View and Text with a Flexbox subset. There is no cascade and no class names, so styles come from StyleSheet.create and get applied per element. Money is stored in cents everywhere and only formatted at the last moment.
// lib/pdf/invoice-document.tsx
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
export interface InvoiceLine {
description: string;
quantity: number;
unitAmountCents: number;
amountCents: number;
}
export interface InvoiceData {
number: string;
issuedAt: string;
currency: string;
customerName: string;
customerEmail: string;
issuerName: string;
issuerAddress: string;
footer: string;
lines: InvoiceLine[];
subtotalCents: number;
taxCents: number;
totalCents: number;
}
const styles = StyleSheet.create({
page: { padding: 44, fontSize: 10, fontFamily: "Helvetica", color: "#18181b" },
bar: { height: 6, backgroundColor: "#4f46e5", marginBottom: 28 },
header: { flexDirection: "row", justifyContent: "space-between", marginBottom: 32 },
brand: { fontSize: 18, fontFamily: "Helvetica-Bold" },
muted: { color: "#71717a" },
right: { textAlign: "right" },
parties: { flexDirection: "row", justifyContent: "space-between", marginBottom: 28 },
label: { fontSize: 8, letterSpacing: 1, color: "#71717a", marginBottom: 4 },
row: { flexDirection: "row", paddingVertical: 7 },
headRow: { borderBottomWidth: 1, borderBottomColor: "#18181b", paddingBottom: 5 },
bodyRow: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7" },
cDesc: { flex: 4 },
cQty: { flex: 1, textAlign: "right" },
cUnit: { flex: 1.4, textAlign: "right" },
cAmt: { flex: 1.4, textAlign: "right" },
totals: { marginTop: 18, marginLeft: "auto", width: 220 },
totalRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 4 },
grand: { fontFamily: "Helvetica-Bold", borderTopWidth: 1, borderTopColor: "#18181b", marginTop: 6, paddingTop: 8 },
footer: { position: "absolute", bottom: 32, left: 44, right: 44, fontSize: 8, color: "#71717a" },
});
function money(cents: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(cents / 100);
}
export function InvoiceDocument({ data }: { data: InvoiceData }) {
return (
<Document title={`Invoice ${data.number}`} author={data.issuerName}>
<Page size="A4" style={styles.page}>
<View style={styles.bar} />
<View style={styles.header}>
<View>
<Text style={styles.brand}>{data.issuerName}</Text>
<Text style={styles.muted}>{data.issuerAddress}</Text>
</View>
<View style={styles.right}>
<Text style={styles.brand}>Invoice {data.number}</Text>
<Text style={styles.muted}>Issued {data.issuedAt}</Text>
<Text style={styles.muted}>Paid in full</Text>
</View>
</View>
<View style={styles.parties}>
<View>
<Text style={styles.label}>BILLED TO</Text>
<Text>{data.customerName}</Text>
<Text style={styles.muted}>{data.customerEmail}</Text>
</View>
</View>
<View style={[styles.row, styles.headRow]}>
<Text style={styles.cDesc}>Description</Text>
<Text style={styles.cQty}>Qty</Text>
<Text style={styles.cUnit}>Unit</Text>
<Text style={styles.cAmt}>Amount</Text>
</View>
{data.lines.map((line, index) => (
<View key={index} style={[styles.row, styles.bodyRow]}>
<Text style={styles.cDesc}>{line.description}</Text>
<Text style={styles.cQty}>{line.quantity}</Text>
<Text style={styles.cUnit}>{money(line.unitAmountCents, data.currency)}</Text>
<Text style={styles.cAmt}>{money(line.amountCents, data.currency)}</Text>
</View>
))}
<View style={styles.totals}>
<View style={styles.totalRow}>
<Text style={styles.muted}>Subtotal</Text>
<Text>{money(data.subtotalCents, data.currency)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.muted}>Tax</Text>
<Text>{money(data.taxCents, data.currency)}</Text>
</View>
<View style={[styles.totalRow, styles.grand]}>
<Text>Total paid</Text>
<Text>{money(data.totalCents, data.currency)}</Text>
</View>
</View>
<Text style={styles.footer} fixed>
{data.footer}
</Text>
</Page>
</Document>
);
}A few constraints are worth internalizing before you fight the layout. There is no cascade, so a style on a parent View does not reach the Text inside it. Flexbox is supported but Grid is not, which is why the line item table is built from rows of flexed columns rather than a real table. The style prop accepts an array, and later entries win, which is how the header and body rows share a base row style. And position: "absolute" combined with the fixed prop on the footer is what pins the legal line to the bottom of every page instead of letting it float after the last line item.
Your company name, address, and legal footer do not change per invoice, so they should not be fetched per invoice either. This helper is a good fit for "use cache". Note that cached functions cannot read cookies, which is exactly why it uses the admin client rather than a request-scoped one.
// lib/billing/issuer.ts
import { cacheLife, cacheTag } from "next/cache";
import { supabaseAdmin } from "@/lib/supabase/admin";
export async function getIssuerProfile() {
"use cache";
cacheLife("days");
cacheTag("issuer-profile");
const { data } = await supabaseAdmin
.from("billing_settings")
.select("issuer_name, issuer_address, invoice_footer")
.single();
return {
issuerName: data?.issuer_name ?? "Acme Inc.",
issuerAddress: data?.issuer_address ?? "1 Market St, San Francisco, CA",
footer: data?.invoice_footer ?? "Thanks for your business. Questions? billing@acme.com",
};
}Rendering Inside an Inngest Step
Here is the detail that costs people an afternoon. Everything a step.run returns gets serialized to JSON so Inngest can checkpoint it. A Node Buffer does not survive that trip, and neither do Date objects or Map instances. So the render and the upload live in the same step, and that step returns only the storage path (a string). The email step downloads the file back out of storage when it needs the bytes.
Because the function contains JSX, the file has to be a .tsx file. That is easy to miss when everything else in your Inngest folder is .ts.
// lib/inngest/functions/generate-invoice-pdf.tsx
import { renderToBuffer } from "@react-pdf/renderer";
import { Resend } from "resend";
import { inngest } from "@/lib/inngest/client";
import { stripe } from "@/lib/stripe";
import { supabaseAdmin } from "@/lib/supabase/admin";
import { getIssuerProfile } from "@/lib/billing/issuer";
import { InvoiceDocument } from "@/lib/pdf/invoice-document";
const resend = new Resend(process.env.RESEND_API_KEY!);
export const generateInvoicePdf = inngest.createFunction(
{ id: "generate-invoice-pdf", retries: 4, concurrency: { limit: 5 } },
{ event: "billing/invoice.paid" },
async ({ event, step }) => {
const { stripeInvoiceId, userId } = event.data;
const data = await step.run("load-invoice", async () => {
const invoice = await stripe.invoices.retrieve(stripeInvoiceId);
const issuer = await getIssuerProfile();
const subtotal = invoice.subtotal ?? 0;
const total = invoice.total ?? 0;
const paidAt = invoice.status_transitions?.paid_at ?? invoice.created;
return {
number: invoice.number ?? stripeInvoiceId,
issuedAt: new Date(paidAt * 1000).toISOString().slice(0, 10),
currency: invoice.currency,
customerName: invoice.customer_name ?? "Customer",
customerEmail: invoice.customer_email ?? "",
issuerName: issuer.issuerName,
issuerAddress: issuer.issuerAddress,
footer: issuer.footer,
lines: invoice.lines.data.map((line) => {
const quantity = line.quantity ?? 1;
return {
description: line.description ?? "Subscription",
quantity,
unitAmountCents: quantity > 0 ? Math.round(line.amount / quantity) : line.amount,
amountCents: line.amount,
};
}),
subtotalCents: subtotal,
taxCents: total - subtotal,
totalCents: invoice.amount_paid,
};
});
const pdfPath = await step.run("render-and-store", async () => {
const buffer = await renderToBuffer(<InvoiceDocument data={data} />);
const path = `${userId}/${data.number}.pdf`;
const { error } = await supabaseAdmin.storage
.from("invoices")
.upload(path, buffer, { contentType: "application/pdf", upsert: true });
if (error) throw new Error(`Upload failed: ${error.message}`);
return path;
});
await step.run("record-invoice", async () => {
const { error } = await supabaseAdmin.from("invoices").upsert(
{
user_id: userId,
stripe_invoice_id: stripeInvoiceId,
number: data.number,
currency: data.currency,
amount_paid_cents: data.totalCents,
pdf_path: pdfPath,
issued_at: new Date(data.issuedAt).toISOString(),
},
{ onConflict: "stripe_invoice_id" },
);
if (error) throw new Error(`Ledger write failed: ${error.message}`);
});
await step.run("email-receipt", async () => {
if (!data.customerEmail) return { skipped: true };
const { data: file, error } = await supabaseAdmin.storage
.from("invoices")
.download(pdfPath);
if (error || !file) throw new Error("Could not read the stored PDF");
const buffer = Buffer.from(await file.arrayBuffer());
await resend.emails.send({
from: `${data.issuerName} Billing <billing@acme.com>`,
to: data.customerEmail,
subject: `Your receipt ${data.number}`,
html: `<p>Thanks for your payment. Your receipt is attached, and every invoice is available in your billing history.</p>`,
attachments: [{ filename: `${data.number}.pdf`, content: buffer }],
});
return { skipped: false };
});
return { pdfPath };
},
);Resend accepts an attachment as an object with filename and content, where content is a Buffer or a base64 string, with a 40MB ceiling per email after encoding. An invoice PDF is a few kilobytes, so that limit only matters if you ever batch a year of them into one message.
The client and the serve route are the standard Inngest setup. The serve handler exports all three HTTP methods so Inngest can introspect the function list as well as invoke it.
// lib/inngest/client.ts
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "billing-app" });// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import { generateInvoicePdf } from "@/lib/inngest/functions/generate-invoice-pdf";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [generateInvoicePdf],
});The Webhook That Starts It
The webhook does three things and nothing more: verify the signature against the raw body, map the Stripe customer to your user, and send one event. Passing an id on the Inngest event gives you deduplication, so a redelivered Stripe webhook does not produce a second run.
// app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { inngest } from "@/lib/inngest/client";
import { supabaseAdmin } from "@/lib/supabase/admin";
export async function POST(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "invoice.paid") {
const invoice = event.data.object;
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (customerId) {
const { data: profile } = await supabaseAdmin
.from("profiles")
.select("id")
.eq("stripe_customer_id", customerId)
.maybeSingle();
if (profile) {
await inngest.send({
name: "billing/invoice.paid",
id: `invoice-paid-${invoice.id}`,
data: { stripeInvoiceId: invoice.id, userId: profile.id },
});
}
}
}
return Response.json({ received: true });
}Serving Billing History Behind Signed URLs
Reads go through oRPC so the query shape is validated once and the page compiles against the same contract. The handler uses the request-scoped Supabase client, which means the RLS policy is what actually enforces ownership. The explicit user_id filter is there for index selectivity, not security.
// server/orpc/billing.ts
import { os, ORPCError } from "@orpc/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
export interface Context {
userId: string;
}
const base = os.$context<Context>();
export const listInvoices = base
.input(z.object({ limit: z.number().int().min(1).max(100).default(24) }))
.handler(async ({ input, context }) => {
const supabase = await createClient();
const { data, error } = await supabase
.from("invoices")
.select("id, number, currency, amount_paid_cents, issued_at, pdf_path")
.eq("user_id", context.userId)
.order("issued_at", { ascending: false })
.limit(input.limit);
if (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message });
}
return data ?? [];
});
export const router = { listInvoices };The download route awaits params as Next.js 16 requires, reads the row through the user's own client so RLS decides whether they may see it, then mints a 60 second signed URL with the service role client. The download option sets the filename the browser saves, which is the difference between invoice-2026-0142.pdf and a random storage key.
// app/api/invoices/[id]/download/route.ts
import { createClient } from "@/lib/supabase/server";
import { supabaseAdmin } from "@/lib/supabase/admin";
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
const { id } = await params;
const supabase = await createClient();
const { data: claims } = await supabase.auth.getClaims();
if (!claims?.claims.sub) {
return new Response("Unauthorized", { status: 401 });
}
const { data: invoice } = await supabase
.from("invoices")
.select("number, pdf_path")
.eq("id", id)
.maybeSingle();
if (!invoice?.pdf_path) {
return new Response("Not found", { status: 404 });
}
const { data: signed, error } = await supabaseAdmin.storage
.from("invoices")
.createSignedUrl(invoice.pdf_path, 60, { download: `${invoice.number}.pdf` });
if (error || !signed) {
return new Response("Not found", { status: 404 });
}
return Response.redirect(signed.signedUrl, 302);
}The billing page itself is a Server Component. It must not carry "use cache", because the data is per-user and session dependent. Caching it would be the exact bug this whole design exists to prevent.
// app/(app)/billing/page.tsx
import { createRouterClient } from "@orpc/server";
import { router } from "@/server/orpc/billing";
import { createContext } from "@/server/orpc/context";
export default async function BillingPage() {
const client = createRouterClient(router, { context: await createContext() });
const invoices = await client.listInvoices({ limit: 24 });
return (
<main className="mx-auto max-w-2xl py-12">
<h1 className="mb-6 text-2xl font-bold">Billing history</h1>
<ul className="divide-y divide-border rounded-lg border">
{invoices.map((invoice) => (
<li key={invoice.id} className="flex items-center justify-between p-4">
<div>
<p className="font-medium">{invoice.number}</p>
<p className="text-sm text-muted-foreground">
{new Date(invoice.issued_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4">
<span className="tabular-nums">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: invoice.currency.toUpperCase(),
}).format(invoice.amount_paid_cents / 100)}
</span>
<a
href={`/api/invoices/${invoice.id}/download`}
className="text-sm font-medium underline"
>
Download
</a>
</div>
</li>
))}
</ul>
</main>
);
}Testing the Chain Locally
Three terminals and one command replay the whole pipeline without waiting for a real customer. The Stripe CLI forwards live test-mode events to your local webhook, and the Inngest dev server picks up the function.
npm run dev
npx inngest-cli dev -u http://localhost:3000/api/inngest
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger invoice.paidOpen the Inngest dev UI at http://localhost:8288 and you can see each step, its output, and its timing. When something fails, you get the exact step that threw and the ability to replay from there instead of re-triggering Stripe. Ask Claude Code to run the trigger and read the run output, and it will trace a failing upload back to a missing bucket policy on its own.
The Gotchas That Bite
Fonts. React-PDF ships with the standard PDF fonts (Helvetica, Times, Courier). Anything else needs Font.register with a TTF the renderer can load at runtime, and a font that fails to load produces a blank document rather than an error. Start with Helvetica and add a brand font only once the pipeline is green.
Zero-decimal currencies. Dividing by 100 is wrong for JPY, KRW, and a handful of others where the smallest unit is the whole unit. If you sell in those, branch on the currency before formatting instead of assuming two decimal places.
Invoice numbers as paths. Stripe invoice numbers are unique per account and safe in a path, but only if you fall back to the invoice id when number is null (which happens on draft invoices). The code above does that fallback deliberately.
Tax fields drift. Stripe has reshaped its tax fields across API versions. Deriving tax as total minus subtotal, as the function does, keeps working across version bumps. Read the specific field only if you need the breakdown per rate.
Line items are paginated. invoice.lines is a list object, and Stripe returns a bounded page of it, not necessarily everything. For a normal subscription charge with one or two lines this never matters. For a metered or seat-heavy invoice it silently truncates the PDF, which is the kind of bug you find from a customer email. If your invoices can run long, page through the line items explicitly instead of reading invoice.lines.data once.
Function limits on serverless. Rendering is CPU work happening inside the Inngest step, which runs in your Next.js function. A one-page invoice renders fast, but a 40-page one with a registered font will push both memory and duration. Keep an eye on your function configuration, and if documents get large, split the render across steps by page group so no single invocation carries the whole job.
Do not put invoices in a public bucket. A public bucket means anyone who guesses a path reads a customer's name, email, and purchase history. Private bucket plus a signed URL that expires in 60 seconds is barely more code and a completely different risk profile.
Where the Harness Fits
A pipeline like this is five files that all have to agree: the webhook shape, the event payload, the PDF props, the storage path, and the table columns. Claude Code writes any one of them well, and the failure mode is drift between them. That is what the $29 Code Kit is for. It is a one-time purchase, no subscription, and it is a harness on top of Claude Code rather than a replacement for it (you still need a paid Anthropic plan). The loop is plan, build, evaluate, test, then quality gates that do not negotiate: zero type errors, zero lint errors, clean build. On a pipeline where a wrong type only shows up when a real customer pays, having that run automatically after every hand-off is the difference between shipping it and babysitting it.
Posted by @speedy_devv
Vous voulez le framework derrière ces projets ?
Obtenez le système Claude Code que nous utilisons pour planifier, construire, tester et livrer des logiciels en production.
Localization and i18n
A complete Next.js i18n setup with next-intl: locale-routed segments, server-component message loading, hreflang tags, locale-aware Resend emails, and a Claude Code subagent that translates your catalogs on commit.
Referral Program
Build a refer-a-friend feature end to end in Next.js 16: unique referral codes, cookie plus server-side attribution, a Stripe coupon and account credit granted on the first paid invoice, self-referral and fraud guards, and a referrer dashboard.

