When a customer pays for your SaaS, Stripe sends them a receipt email. It has the amount, the date, a transaction ID, and a link to the Stripe-hosted receipt page. For individual users, that is fine.
But the moment you land a business customer -- and business customers are often your most valuable ones -- the receipt is not enough. Their accounting team needs:
in_1ABC3XyzPZ)Stripe does offer its own invoicing product, but it is tightly coupled to how you configure Stripe Billing, limits how much you can control the layout, and adds surface area to your billing setup. If you want invoices that match your branding and give you full control -- you generate the PDF yourself.
Before writing a line of code, answer this: does the invoice need to be the same PDF every time?
On-demand generation is simpler. A user requests an invoice, your route pulls the payment data from your database, renders the PDF in memory, and streams it back. No file storage needed. This works well if your pricing is fixed -- one amount, one line item, no adjustments.
Stored PDFs make sense when the invoice data could change after the fact: per-seat billing, prorations, mid-cycle upgrades, or credit notes. In that case, generate and store the PDF at the moment payment is confirmed, then serve it from Cloudinary (already wired into the Next.js SaaS boilerplate). The URL stays stable and your accounting customers can pull it any time without triggering a regeneration.
For most early-stage SaaS products, start with on-demand generation. You can add Cloudinary storage later when the need appears.
Two libraries work well inside a Next.js API route:
@react-pdf/renderer -- write the invoice layout in JSX-like components. If you already know React, the learning curve is minimal. Runs server-side only, no browser required, and produces small files.On Vercel's serverless infrastructure, @react-pdf/renderer is the practical choice. It keeps the function lightweight and the response fast.
The pattern follows the same shape as every other feature in this codebase: a thin route that validates the request, a service that holds the business logic, and a repository that handles the database query.
Here is what the route looks like:
// app/api/invoices/[paymentId]/route.ts
export async function GET(req: Request, { params }: { params: { paymentId: string } }) {
try {
const user = await getUserFromRequest(req);
const buffer = await invoiceService.generatePdf(params.paymentId, user.id);
return new Response(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${params.paymentId}.pdf"`,
},
});
} catch (error: unknown) {
return handleError(error);
}
}
The route never sees the invoice layout. The service fetches the payment record, confirms the requesting user owns it, renders the PDF with @react-pdf/renderer, and returns a Buffer. The ownership check keeps one customer from downloading another's invoice -- the same pattern described in the authentication docs.
This approach pairs naturally with the existing Stripe one-time payments flow and the Stripe subscriptions setup -- the payment record those flows create in Drizzle ORM is exactly what you query when building the invoice.
For version one, keep the required fields small:
Your company name and address (from env vars)
Invoice number (sequential counter, stored in DB)
Issue date
Customer name and billing address (stored at Stripe checkout)
Line items: description, quantity, unit price, subtotal
Total amount
Payment status: Paid
Once that works, add your logo as a Cloudinary URL, tax IDs for EU customers, and a billing history page in the app that shows all past invoices with download links.
Do not use the Stripe payment ID as your invoice number. Sequential numbers (INV-0001, INV-0002) are a legal requirement in many countries and an accounting best practice everywhere. Store a counter in your database -- either a dedicated invoiceNumber sequence column or a simple integer on the payment record -- and increment it atomically when the invoice is first generated. Drizzle ORM transactions (covered in the database docs) make this straightforward to do without a race condition.
On the billing history page, each payment row gets a "Download Invoice" button. The button calls GET /api/invoices/[paymentId] with the bearer token from your auth context and triggers the browser's file download via a blob response. No new libraries needed on the frontend -- the same fetcher from lib/fetcher.ts handles it with a responseType: 'blob' option.
PDF invoice generation sits cleanly on top of what the boilerplate already gives you: JWT auth, Stripe payment records in Drizzle ORM, and Cloudinary for optional file storage. You are not rebuilding infrastructure -- you are adding one service file, one route, and one UI component.
Get the Next.js SaaS boilerplate and ship the invoice feature your business customers are already asking for. Get started with the getting started guide and have it working this weekend.