Introduction
In a SaaS world where recurring revenue is king, automating billing prevents manual errors and scales effortlessly. This intermediate tutorial shows you how to integrate Stripe for managing subscriptions, generating automatic PDF invoices, and sending emails via webhooks in a Next.js 15 app with Prisma.
Why is this essential in 2026? Regulations like PSD3 demand traceability and compliance, while users expect instant notifications. Imagine: a customer subscribes, Stripe charges automatically, a webhook triggers a custom PDF and email—all hands-free.
We start from the basics (project setup) and go advanced (cron jobs for usage-based billing), with 100% working code. By the end, your system handles 1000+ customers effortlessly. Estimated time: 30 minutes for a functional MVP.
Prerequisites
- Node.js 20+ and npm/yarn
- Stripe account (test mode enabled, API keys copied)
- Knowledge of TypeScript, Next.js App Router, and Prisma
- Vercel or server for deployment (public webhooks required)
- Tools: VS Code, Prisma Studio
Initialize the Next.js Project
npx create-next-app@latest billing-automation --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd billing-automation
npm install stripe @prisma/client prisma resend pdf-lib cron
npm install -D @types/node
npx prisma init --datasource-provider sqliteThis command creates a Next.js 15 project with TypeScript and Tailwind, installs Stripe for billing, Prisma for the DB, Resend for emails, pdf-lib for PDFs, and cron for scheduling. SQLite simplifies local testing; switch to PostgreSQL in production. Use --app to avoid conflicts with App Router.
Set Up Environment and Prisma
Copy these environment variables into .env:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
RESEND_API_KEY=re_...
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET=supersecret
Update prisma/schema.prisma for users and subscriptions (see next code block). Run npx prisma db push afterward. Think of Prisma as your 'Lego' ORM—schemas define the DB structure like interconnecting blocks.
Define the Prisma Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String?
subscriptions Json[] @default([])
createdAt DateTime @default(now())
}
model Invoice {
id String @id @default(cuid())
userId String
stripeId String @unique
pdfUrl String?
status String // paid, open, voided
amount Int
currency String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
}This schema links users to Stripe customers and tracks invoices with generated PDFs. Json[] allows flexible subscriptions (usage-based). SQLite for dev; in production, index stripeId as unique for fast lookups. Pitfall: forgetting @unique on stripeId causes duplicates in webhooks.
Generate and Migrate the DB
npx prisma generate
npx prisma db push
npx prisma studioGenerates the Prisma client, pushes the schema to the DB, and opens Studio for inspection. Use db push for iterative dev vs. migrations in production. Check User/Invoice tables; add a test user via Studio.
Create a Stripe Customer and Checkout
Create an API route for onboarding: link a user to a Stripe customer, then generate a checkout for a monthly subscription ($49). Use a Price ID from your Stripe dashboard (create a recurring price first).
API: Create Customer and Checkout
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { PrismaClient } from '@prisma/client';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' });
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
const { userId, email } = await req.json();
let user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
user = await prisma.user.create({ data: { email } });
}
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({ email });
customerId = customer.id;
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: 'price_12345', quantity: 1 }],
success_url: `${req.headers.get('origin')}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.get('origin')}/?canceled=true`,
});
return NextResponse.json({ url: session.url });
}This POST route creates/upserts a user, Stripe customer, and checkout session. Replace 'price_12345' with your Price ID. Pitfall: without customer reuse, you'll create unnecessary customers (costly and confusing). Test with curl or frontend fetch.
Handle Stripe Webhooks for Automation
Stripe sends events (like invoice.paid) via webhooks. We'll handle them by generating a PDF invoice, storing the URL in the DB, and sending an email via Resend. Deploy to Vercel for a public URL, then add the webhook endpoint in your Stripe dashboard.
API Webhook Handler
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { PrismaClient } from '@prisma/client';
import { Resend } from 'resend';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' });
const prisma = new PrismaClient();
const resend = new Resend(process.env.RESEND_API_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'Webhook signature verification failed' }, { status: 400 });
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const user = await prisma.user.findFirst({ where: { stripeCustomerId: invoice.customer as string } });
if (!user) return NextResponse.json({ received: true });
// Generate PDF
const pdfDoc = await PDFDocument.create();
const timesRomanFont = await pdfDoc.embedFont(StandardFonts.TimesRoman);
const page = pdfDoc.addPage([500, 600]);
page.drawText(`Invoice #${invoice.number}`, {
x: 50,
y: 550,
size: 20,
font: timesRomanFont,
color: rgb(0, 0, 0),
});
page.drawText(`${invoice.amount_paid / 100}€`, { x: 50, y: 500, size: 16, font: timesRomanFont });
const pdfBytes = await pdfDoc.save();
// TODO: Upload PDF to S3/Cloudinary, simulate URL here
const pdfUrl = 'https://example.com/invoice.pdf'; // Replace with real upload
await prisma.invoice.create({
data: {
userId: user.id,
stripeId: invoice.id,
pdfUrl,
status: invoice.status,
amount: invoice.amount_paid,
currency: invoice.currency,
},
});
// Send email
await resend.emails.send({
from: 'billing@yourapp.com',
to: [user.email],
subject: `Invoice paid #${invoice.number}`,
html: `<p>Your invoice is ready: <a href="${pdfUrl}">Download</a></p>`,
});
}
return NextResponse.json({ received: true });
}Validates the webhook signature, generates a simple PDF with pdf-lib, stores it in the DB, and sends an email. Integrate real upload (Vercel Blob/S3). Pitfall: without raw body (req.text()), signature fails. Test with Stripe CLI: stripe listen --forward-to localhost:3000/api/webhook.
Cron Job for Usage-Based Billing
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { PrismaClient } from '@prisma/client';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' });
const prisma = new PrismaClient();
export const dynamic = 'force-dynamic';
export async function GET() {
const users = await prisma.user.findMany({ where: { stripeCustomerId: { not: null } } });
for (const user of users) {
// Simulate usage (e.g., API calls count from DB)
const usage = 150; // Replace with real query
await stripe.subscriptionItems.createUsageRecord('si_123', { // SubscriptionItem ID
quantity: usage,
timestamp: Math.floor(Date.now() / 1000),
action: 'set',
});
}
return NextResponse.json({ updated: users.length });
}Monthly cron (Vercel Cron) reports usage for metered billing. Replace 'si_123' with real item ID and query DB usage. Pitfall: without dynamic='force-dynamic', Next.js caching blocks it. Test by calling /api/cron/usage manually.
Testing and Deployment
- Local:
npm run dev, test checkout with Postman (fake userId) - Webhook: Use Stripe CLI to simulate
invoice.paid - Deploy to Vercel: Add cron
0 0 1for usage - Monitor Stripe dashboard and Resend logs
fetch('/api/checkout', {method:'POST', body:JSON.stringify({userId:'test', email:'test@example.com'})}).Best Practices
- Idempotent webhooks: Check event ID in DB to avoid duplicates
- Security: Always validate signatures; pin Stripe API versions
- Scalability: Batch usage reports (>1000 users); offload PDFs to queues (BullMQ)
- Compliance: Store invoices for 10 years; add VAT via Stripe Tax
- Monitoring: Integrate Sentry for webhook failures
Common Errors to Avoid
- Forgetting
req.text()in webhook: signature fails - No customer reuse: explodes customer count (Stripe limits)
- Ignoring timezones in cron: misaligned usage reports
- PDFs without upload: broken URLs; integrate Cloudinary early
- Testing without Stripe CLI: missed events in dev
Next Steps
- Stripe Billing Docs: stripe.com/docs/billing
- Advanced: Customer Portal, Usage webhooks
- Learni Courses: Discover our Next.js & Stripe courses
- Example GitHub Repo: github.com/learni/billing-automation-2026