Chapter 11: Payments and Subscriptions (Stripe)
Welcome to Part Five, the core business logic of our SAAS application. In previous chapters, we built a solid application foundation—from frontend UI, RSC data flow, secure Server Actions to type-safe Drizzle ORM and 'better-auth' authentication. Now, it's time to transform our application from a 'project' into a 'product': implementing paid subscriptions.
Chapter 11: Payments and Subscriptions (Stripe)
Welcome to Part Five, the core business logic of our SAAS application. In previous chapters, we built a solid application foundation—from frontend UI, RSC data flow, secure Server Actions to type-safe Drizzle ORM and 'better-auth' authentication. Now, it's time to transform our application from a "project" into a "product": implementing paid subscriptions.
For Python developers, you might be familiar with the stripe-python library, creating payment intents through Flask or Django API routes. In the Next.js full-stack architecture, the logic is similar, but the implementation is more modern and integrated. We'll use Server Actions to initiate payment flows and Route Handlers to receive status updates from Stripe (Webhooks).
In this chapter, we'll dive into the src/payment/ directory of our实战项目, integrating Stripe Checkout and Customer Portal, and building a robust Webhook system to handle subscription lifecycles.
11.1. Architecture: Stripe Checkout vs. Elements
When integrating Stripe, the first architectural decision you face is: should you use Stripe Checkout or Stripe Elements?
Path A: Stripe Checkout (Stripe-Hosted Payment Page)
- What it is: A complete payment page hosted and optimized by Stripe.
- Flow:
- Your server (via Server Action) creates a "Checkout Session".
- The server returns that Session's URL.
- Your client (browser) redirects to this Stripe URL.
- User completes payment on Stripe's page.
- Stripe redirects user back to your application (
success_urlorcancel_url).
- Advantages:
- Extremely fast integration: Minimal work, just a few lines of code.
- Compliance: Automatically handles complex issues like PCI, SCA (Strong Customer Authentication), 3D Secure.
- High conversion rates: Stripe continuously optimizes this page, supporting multiple payment methods (Apple Pay, Google Pay, credit cards), automatic localization, and address autocomplete.
- Disadvantages:
- Low customization: You can't fully control the page's UI/UX.
- Temporary redirect: Users briefly leave your website.
Path B: Stripe Elements (Embedded UI Components)
- What it is: A set of pre-built UI components provided by Stripe (like card number, expiration, CVC inputs) that you can embed in your own payment form.
- Flow:
- You build a form (
<form>) in your application. - Use
@stripe/react-stripe-jsto embed Elements into the form. - When user submits the form, your client JS first calls
stripe.confirmPayment(). - You handle the payment intent status on the server side.
- You build a form (
- Advantages:
- Complete control: UI/UX seamlessly integrates with your application, users never leave your website.
- Flexibility: Can build very complex payment flows.
- Disadvantages:
- Complex integration: You need to handle UI state, errors, loading, and compliance yourself (though Elements simplifies PCI).
- High maintenance costs: More frontend code to maintain.
Architecture Decision: Checkout + Customer Portal
For our SAAS template project, we adopt a "speed and robustness" strategy:
- New Subscriptions: Use Stripe Checkout. This lets us get users to pay in the fastest, safest, and most compliant way.
- Manage Subscriptions: Use Stripe Customer Portal. This is a Stripe-hosted page similar to Checkout, allowing subscribed users to manage their subscriptions themselves (upgrade/downgrade, cancel, update credit card, view invoices).
This combination (Checkout + Customer Portal) provides SAAS with the best ROI, letting us focus on core business logic rather than payment form UI details.
11.2. Core: Webhook Handling and Idempotency (Rule 3)
If Checkout is the front door where users initiate payment, then Webhooks are the back door where Stripe notifies you of payment results. You can never trust client redirects (users might close their browser after payment succeeds but before redirecting to success_url).
Webhooks are the only reliable mechanism for keeping your application (Drizzle database) in sync with Stripe (the source of truth).
What is a Webhook?
A Webhook is an API endpoint, in our project it's a Route Handler (e.g., src/app/api/stripe/webhook/route.ts). When specific events occur in Stripe (like invoice.paid, customer.subscription.deleted), Stripe sends a POST request to this URL, containing detailed information about that event in the body.
Signature Verification
Anyone can send a POST request to your API endpoint. How do you ensure this request really came from Stripe?
The answer is Webhook Signatures. Stripe signs each event with a "signing secret" (Webhook Secret) that only you and Stripe know, and puts that signature in the Stripe-Signature request header.
In your Route Handler, the first thing is to verify this signature:
// src/app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe'; // Your Stripe Node.js instance
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('Stripe-Signature') as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
// Signature verification failed
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// ... handle event ...
}Architect's Rule Three: Webhooks Must Be Idempotent
Architect's Rule Three: Webhooks must be idempotent.
Networks are unreliable. Stripe might retry sending the same Webhook event multiple times due to your server being temporarily down, timeout, or not returning 200 OK status.
Idempotency means "executing the same operation multiple times produces exactly the same result as executing it once."
If an invoice.paid event causes you to add 100 credits to a user. If this event is retried 3 times, you must not add 300 credits to the user.
Strategies for implementing idempotency:
- Event ID checking: The simplest method. Create a
processed_stripe_eventstable in Drizzle. When receiving an event, first check ifevent.idalready exists in that table. If it exists, immediately return200 OKand stop processing. If not, process the event, then storeevent.idin the table (all in one database transaction). - Business logic-based checking: When handling
checkout.session.completed, your logic is "activate subscription for that customer." If that customer's subscription is already active, your code should not execute activation logic again, but directly return success.
In our SAAS template, we'll combine both strategies.
11.3. [Code Analysis]: Analyzing src/payment/ Directory
Let's dive into the src/payment/ directory (and related src/actions/ and src/app/api/) to see how our SAAS template implements subscriptions and customer portal.
Architecture Overview
Our payment flow has two distinct parts, using two different Next.js features:
- User-initiated actions (Server Actions):
createCheckoutSession(create subscription)createCustomerPortalSession(manage subscription)- These actions are triggered by users clicking buttons on the client side.
- Stripe-initiated actions (Route Handler):
src/app/api/stripe/webhook/route.ts- This endpoint is called by Stripe's servers in the background to sync status.
File 1: src/actions/payment.actions.ts
This file contains all Server Actions called by users.
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth'; // Our 'better-auth' instance
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
// Get or create Stripe customer ID
async function getOrCreateStripeCustomerId(): Promise<string> {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) throw new Error('User not found');
if (user.stripeCustomerId) {
return user.stripeCustomerId;
}
// Create new customer on Stripe
const customer = await stripe.customers.create({
email: user.email!,
name: user.name,
metadata: {
userId: user.id,
},
});
// Store new ID back to our Drizzle database
await db
.update(users)
.set({ stripeCustomerId: customer.id })
.where(eq(users.id, user.id));
return customer.id;
}
// Server Action: Create Stripe Checkout session
export async function createCheckoutSession(priceId: string) {
const customerId = await getOrCreateStripeCustomerId();
const session = await auth();
const YOUR_DOMAIN = process.env.NEXT_PUBLIC_APP_URL!;
try {
const checkoutSession = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'subscription',
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${YOUR_DOMAIN}/dashboard?payment=success`,
cancel_url: `${YOUR_DOMAIN}/dashboard?payment=cancelled`,
// Pass user ID to webhook so we know who subscribed
metadata: {
userId: session?.user?.id,
}
});
// Return session URL, client will redirect
return { url: checkoutSession.url };
} catch (error) {
console.error(error);
return { error: 'Failed to create checkout session.' };
}
}
// Server Action: Create customer portal session
export async function createCustomerPortalSession() {
const customerId = await getOrCreateStripeCustomerId();
const YOUR_DOMAIN = process.env.NEXT_PUBLIC_APP_URL!;
try {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${YOUR_DOMAIN}/dashboard/settings`,
});
// Return URL, client redirects
return { url: portalSession.url };
} catch (error) {
console.error(error);
return { error: 'Failed to create customer portal session.' };
}
}File 2: src/app/api/stripe/webhook/route.ts
This is our backend "sync center," implementing the signature verification and event handling discussed in section 11.2.
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/db';
import { users, subscriptions } from '@/db/schema'; // Assuming we have a subscriptions table
import { eq } from 'drizzle-orm';
// Helper function to update database
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const subscriptionId = session.subscription as string;
if (!userId) {
console.error('Webhook Error: Missing userId in session metadata');
return;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Update/create subscription record in Drizzle
// Note: This logic needs to be idempotent!
// Assuming we use 'upsert' (update or insert) logic
await db.insert(subscriptions).values({
userId: userId,
stripeSubscriptionId: subscription.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}).onConflictDoUpdate({
target: subscriptions.stripeSubscriptionId,
set: {
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}
});
// Also update user table status
await db.update(users)
.set({ subscriptionStatus: subscription.status })
.where(eq(users.id, userId));
}
// Helper function: Handle subscription update or cancellation
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await db.update(subscriptions)
.set({
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
// (Optional) Also update users table
}
// Webhook main handler
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('Stripe-Signature') as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle verified events
try {
switch (event.type) {
case 'checkout.session.completed':
// First subscription success
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'invoice.paid':
// Renewal success
// Note: 'checkout.session.completed' also triggers 'invoice.paid'
// Make sure your logic handles this properly
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
case 'customer.subscription.updated':
// Subscription status change (cancel, upgrade, downgrade)
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
default:
console.warn(`Unhandled event type: ${event.type}`);
}
} catch (error) {
console.error('Webhook handler failed:', error);
return new Response('Webhook handler failed.', { status: 500 });
}
// Return 200 OK to Stripe, confirming event was successfully received
return new Response(null, { status: 200 });
}File 3: src/components/payment/PricingTable.tsx
Finally, we need a client component to call our Server Action.
'use client';
import { useState } from 'react';
import { createCheckoutSession } from '@/actions/payment.actions';
import { Button } from '@/components/ui/button';
import { useFormStatus } from 'react-dom';
// Internal component for displaying loading state
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button disabled={pending}>
{pending ? 'Processing...' : 'Upgrade Now'}
</Button>
);
}
export function PricingPlan({ priceId }: { priceId: string }) {
const [error, setError] = useState<string | null>(null);
// Use Server Action
const handleUpgrade = async () => {
setError(null);
const result = await createCheckoutSession(priceId);
if (result.error) {
setError(result.error);
} else if (result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url;
}
};
return (
<div>
<h3>Pro Plan</h3>
{/* Here we don't use <form action={...}>
because we need to handle the returned URL on the client and perform redirect.
We call the action function directly.
*/}
<Button onClick={handleUpgrade}>Upgrade Now</Button>
{/* Or, use <form> and useFormStatus:
<form action={async () => {
const res = await createCheckoutSession(priceId);
if (res.url) window.location.href = res.url;
// ... handle error
}}>
<SubmitButton />
</form>
*/}
{error && <p className="text-red-500">{error}</p>}
</div>
);
}Chapter 11: Payments and Subscriptions (Stripe) - Summary
This chapter builds the core paid subscription functionality for our SAAS application. We compared two architectures: Stripe Checkout (hosted page) and Stripe Elements (embedded UI), ultimately choosing the Checkout + Customer Portal combination strategy for rapid integration and convenient management.
The core of this chapter is Webhooks. We emphasized "Architect's Rule Three: Webhooks must be idempotent" because it's the only reliable source for syncing subscription status. At the code level, we implemented clear separation of concerns:
- Server Actions (
payment.actions.ts): Triggered by users on the client side, used to createCheckoutSession(initiate subscriptions) andCustomerPortalSession(manage subscriptions). - Route Handler (
api/stripe/webhook/route.ts): Called by Stripe servers in the background, responsible for verifying Webhook signatures and safely syncing subscription status (likestatusandcurrentPeriodEnd) back to our Drizzle database by handling events likecheckout.session.completed.
Categories
src/actions/payment.actions.tsFile 2: src/app/api/stripe/webhook/route.tsFile 3: src/components/payment/PricingTable.tsxChapter 11: Payments and Subscriptions (Stripe) - SummaryMore Posts
Chapter 13: SAAS Operations: Email and Notifications
A SAAS application cannot operate long-term if it only 'takes in' without 'giving back'. When users perform actions on your platform, the platform must provide feedback in some way. After your application launches and runs, you also need a way to reach your users, whether to notify them of new features or provide help when they encounter problems.
Chapter 10: Database Migrations and Operations
In Chapters 7 and 9, we defined our database table structure in the src/db/schema/ directory. But we left a critical question: when you modify the schema (like adding a bio field to usersTable), how do you safely apply this change to a production database that's already running live?
Chapter 18: Feature Releases: A/B Testing & Feature Flags
Welcome to Part Seven: Advanced Integration & Architecture Practices. In Part Six, we built a complete DevOps and observability stack. We now have: