Stripe for Next.js: Complete Integration Guide (Webhooks, Subscriptions, Payments)
Integrating Stripe with Next.js is one of the first major engineering decisions for any SaaS product. You need payments, subscriptions, webhooks, and a checkout flow that works on every device. But the combination of Next.js App Router architecture and Stripe's extensive API surface means there are dozens of ways to wire things up, and most tutorials only cover the basics.
This stripe nextjs guide covers the complete integration: from initial project setup to production-ready webhook handling, subscription management, and one-time payments. Whether you are building your first SaaS or migrating an existing billing system to Next.js, this guide provides the patterns and code architecture you need.
Stripe processes over $1 trillion in annual payment volume and powers billing for companies like Vercel, Notion, and OpenAI. Combined with Next.js, the most popular React framework for production applications, you get a billing stack that scales from your first customer to millions of transactions.
Key Takeaways
If you remember nothing else:
- Stripe Checkout is the fastest path to accepting payments in Next.js. It handles PCI, mobile, localization, and tax automatically
- Three packages are all you need:
stripe,@stripe/stripe-js, and@stripe/react-stripe-js- Webhooks are essential. Process
checkout.session.completed,invoice.paid, andcustomer.subscription.updatedat minimum- Server Components handle secrets. Keep your Stripe secret key in Route Handlers and Server Actions only
- Stripe CLI is required for local webhook testing. Run
stripe listenduring development- Use a SaaS starter template with pre-built Stripe billing to save 1 to 2 weeks of setup time
Table of Contents
- Setting Up Stripe in a Next.js Project
- Project Architecture for Stripe and Next.js
- Stripe Checkout: The Fastest Path to Payments
- Stripe Elements: Custom Payment Forms
- Webhook Handling in Next.js Route Handlers
- Subscription Management
- One-Time Payments
- The Stripe Customer Portal
- Server Components vs Client Components
- Testing Your Stripe Integration Locally
- Security Best Practices
- Error Handling Patterns
- SaaS Starter Templates with Stripe
- Common Mistakes and How to Avoid Them
- Conclusion
Setting Up Stripe in a Next.js Project
Every stripe nextjs integration starts with three packages and two environment variables. The setup takes about five minutes.
Install the Required Packages
You need three npm packages for a complete stripe nextjs setup:
stripe(v17+): The Node.js server SDK. Use this in Route Handlers and Server Actions for all API calls.@stripe/stripe-js(v4.9+): The client-side loader. Initializes Stripe.js in the browser.@stripe/react-stripe-js(v2.8+): React components for Stripe Elements. Optional if you only use Stripe Checkout.
Install them:
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
Configure Environment Variables
Add these to your .env.local file:
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
The STRIPE_SECRET_KEY is server-only. The NEXT_PUBLIC_ prefix makes the publishable key available to Client Components. The webhook secret verifies that incoming webhook events come from Stripe.
Create the Stripe Server Instance
Create a shared Stripe instance for server-side code:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-29.acacia',
typescript: true,
});
Pin the apiVersion to avoid breaking changes when Stripe releases new API versions. This is your single source of truth for all server-side Stripe operations in your stripe nextjs project.
Create the Client-Side Stripe Loader
Create a client-side loader for Stripe.js:
// lib/stripe-client.ts
import { loadStripe } from '@stripe/stripe-js';
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
Call loadStripe once and reuse the promise. This avoids loading Stripe.js multiple times.
Project Architecture for Stripe and Next.js
A well-structured stripe nextjs project separates server-side billing logic from client-side payment UI. Here is the recommended file structure for the App Router:
app/
api/
webhooks/
stripe/
route.ts # Webhook handler
create-checkout/
route.ts # Create Checkout Sessions
create-portal/
route.ts # Create Customer Portal sessions
(dashboard)/
billing/
page.tsx # Billing management page
subscription/
page.tsx # Subscription status page
pricing/
page.tsx # Public pricing page
lib/
stripe.ts # Server-side Stripe instance
stripe-client.ts # Client-side Stripe loader
stripe-helpers.ts # Shared utility functions
The Data Flow
The billing data flow in a stripe nextjs application follows a consistent pattern:
User Action → Client Component → Route Handler → Stripe API → Webhook → Database → UI Update
Key principles:
- Stripe is the source of truth for all billing data. Never store subscription prices in your database.
- Webhooks sync state. Your database mirrors Stripe's subscription state through webhook events.
- Server-side only. All Stripe API calls with the secret key happen in Route Handlers or Server Actions.
- Checkout handles payment collection. Use Stripe Checkout unless you have a specific reason to build custom forms.
For a broader look at SaaS billing architecture beyond Next.js, see our SaaS Stripe integration guide.
Stripe Checkout: The Fastest Path to Payments
Stripe Checkout is the recommended approach for most stripe nextjs applications. It is a hosted payment page that Stripe optimizes continuously for conversion, handling PCI compliance, mobile responsiveness, and localization automatically.
Why Stripe Checkout
- PCI compliance handled. Stripe manages all card data. You never touch sensitive payment information.
- Mobile-optimized. Responsive design works across all devices without custom CSS.
- Localized. Supports 40+ languages and displays local payment methods automatically.
- Tax integration. Enable
automatic_taxand Stripe calculates VAT, GST, and sales tax. - Higher conversion. Stripe Checkout converts up to 20% better than custom payment forms in their benchmarks.
Creating a Checkout Session
Create a Route Handler that generates Stripe Checkout Sessions:
// app/api/create-checkout/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const { priceId, customerId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
subscription_data: {
trial_period_days: 14,
},
automatic_tax: { enabled: true },
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
} catch (error: any) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
}
Redirecting to Checkout from the Client
On your pricing page, redirect users to the Checkout Session URL:
// components/PricingCard.tsx
'use client';
export function PricingCard({ priceId }: { priceId: string }) {
const handleSubscribe = async () => {
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await response.json();
window.location.href = url;
};
return (
<button onClick={handleSubscribe}>
Subscribe Now
</button>
);
}
This is the simplest and most effective pattern for accepting stripe subscriptions in a Next.js application. The user clicks "Subscribe," your server creates a Checkout Session, and the user completes payment on Stripe's hosted page.
For design guidance on building effective pricing pages that drive conversions, see our SaaS pricing page design guide.
Stripe Elements: Custom Payment Forms
Stripe Elements gives you full control over the payment form's look and feel. Use Elements when your checkout flow requires a deeply embedded experience that Stripe Checkout cannot support.
When to Use Stripe Elements
- Multi-step onboarding where payment collection happens mid-flow
- Custom branding requirements that demand full control over every pixel
- Inline pricing configurators where the price changes based on user selections
- Saved payment methods with a custom card management UI
Setting Up the Elements Provider
Wrap your payment form in the Elements provider:
// components/PaymentForm.tsx
'use client';
import { Elements } from '@stripe/react-stripe-js';
import { stripePromise } from '@/lib/stripe-client';
import { CheckoutForm } from './CheckoutForm';
export function PaymentForm({ clientSecret }: { clientSecret: string }) {
return (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#0070f3',
},
},
}}
>
<CheckoutForm />
</Elements>
);
}
Building the Checkout Form
// components/CheckoutForm.tsx
'use client';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { useState } from 'react';
export function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setIsProcessing(true);
setErrorMessage('');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/dashboard`,
},
});
if (error) {
setErrorMessage(error.message || 'Payment failed.');
}
setIsProcessing(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe || isProcessing}>
{isProcessing ? 'Processing...' : 'Pay Now'}
</button>
{errorMessage && <p>{errorMessage}</p>}
</form>
);
}
The PaymentElement component renders a complete payment form that supports cards, Apple Pay, Google Pay, and other payment methods based on the customer's location. It replaces the older CardElement and handles SCA (Strong Customer Authentication) automatically.
Stripe Checkout vs Stripe Elements: When to Use Which
| Criteria | Stripe Checkout | Stripe Elements |
|---|---|---|
| Setup time | Minutes | Hours to days |
| PCI compliance | Automatic | Automatic (with Elements) |
| Customization | Limited (branding, colors) | Full control |
| Mobile optimization | Built-in | You handle it |
| Conversion rate | Higher (Stripe-optimized) | Depends on implementation |
| Tax handling | Built-in with automatic_tax | Manual integration |
| Best for | Most SaaS applications | Complex custom flows |
For 90% of stripe nextjs applications, Stripe Checkout is the right choice. Only reach for Elements when Checkout genuinely cannot support your checkout flow.
Webhook Handling in Next.js Route Handlers
Stripe webhooks are the backbone of your billing system. They notify your application when payment events occur so you can update subscription state, provision access, and trigger business logic. Getting stripe webhooks right is critical for a reliable billing integration.
Creating the Webhook Route Handler
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing stripe-signature header' },
{ status: 400 }
);
}
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
}
} catch (err) {
console.error('Webhook handler error:', err);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}
Essential Stripe Webhooks for SaaS
| Event | When It Fires | What to Do |
|---|---|---|
checkout.session.completed |
Customer completes checkout | Activate subscription, provision access |
invoice.paid |
Subscription renews successfully | Update current period, confirm access |
invoice.payment_failed |
Payment attempt fails | Start dunning flow, notify customer |
customer.subscription.updated |
Plan change or cancellation scheduled | Update plan and status in database |
customer.subscription.deleted |
Subscription fully canceled | Revoke access, trigger offboarding |
customer.subscription.trial_will_end |
3 days before trial ends | Send conversion email |
Webhook Handler Functions
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
// Fetch the full subscription details
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Update your database
await db.user.update({
where: { stripeCustomerId: customerId },
data: {
subscriptionId: subscription.id,
subscriptionStatus: subscription.status,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subscriptionId = invoice.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: {
subscriptionStatus: 'active',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: {
subscriptionStatus: 'past_due',
},
});
// Trigger dunning email flow
}
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: subscription.status,
priceId: subscription.items.data[0].price.id,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: 'canceled',
subscriptionId: null,
priceId: null,
},
});
}
Webhook Security Rules
- Always verify signatures. Never process a webhook event without calling
constructEvent(). - Process idempotently. Stripe may send the same event multiple times. Use the event
idto avoid duplicate processing. - Return 200 quickly. If your logic takes more than a few seconds, acknowledge the webhook immediately and process asynchronously with a job queue.
- Use HTTPS in production. Stripe will not deliver webhooks to insecure endpoints.
- Handle out-of-order events. Check the
createdtimestamp to process events in the correct order.
For authentication patterns that integrate with webhook-driven subscription state, see our SaaS authentication guide.
Subscription Management
Subscription lifecycle management is where your stripe nextjs integration handles the real complexity. Customers upgrade, downgrade, pause, and cancel. Each action requires specific Stripe API calls and database updates.
Creating Subscriptions via Checkout
The simplest approach is creating subscriptions through Stripe Checkout Sessions with mode: 'subscription'. This handles payment collection, trial setup, and the initial subscription creation in one flow.
Handling Upgrades and Downgrades
Upgrades should apply immediately with proration:
// app/api/subscription/upgrade/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { subscriptionId, newPriceId } = await req.json();
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: 'create_prorations',
}
);
return NextResponse.json(updatedSubscription);
}
Downgrades should apply at the end of the current billing period to avoid issuing refunds:
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: 'none',
}
);
Cancellation Handling
Use end-of-period cancellation as the default. It is less abrupt and gives customers time to reconsider:
// Cancel at end of billing period
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// Immediate cancellation (use sparingly)
await stripe.subscriptions.cancel(subscriptionId);
Subscription State Machine
| State | Meaning | Feature Access |
|---|---|---|
trialing |
In free trial | Full access |
active |
Paying customer | Full access |
past_due |
Payment failed, retrying | Degraded or full access |
canceled |
Subscription ended | No access |
unpaid |
All retries exhausted | No access |
paused |
Customer-initiated pause | No access |
incomplete |
Initial payment failed | No access |
Map these states to feature flags in your application. Check the subscription status on every authenticated request or cache it with a reasonable TTL.
For a deeper dive into pricing models and subscription architecture, see our SaaS billing system guide.
One-Time Payments
Not every payment in a stripe nextjs application is a subscription. You may need one-time payments for credits, add-ons, lifetime deals, or individual product purchases.
Using Checkout for One-Time Payments
// app/api/create-payment/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}
Using Payment Intents for Custom Flows
For custom payment flows with Stripe Elements, create a Payment Intent server-side and confirm it client-side:
// app/api/create-payment-intent/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { amount, currency = 'usd' } = await req.json();
const paymentIntent = await stripe.paymentIntents.create({
amount, // Amount in cents
currency,
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
});
}
Pass the clientSecret to your Stripe Elements component to complete the payment on the client side.
The Stripe Customer Portal
The Stripe Customer Portal is a hosted UI where customers manage their subscriptions without you building custom management screens. It handles payment method updates, plan changes, invoice history, and cancellations.
Setting Up the Portal
Create a Route Handler that generates portal sessions:
// app/api/create-portal/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { customerId } = await req.json();
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: session.url });
}
What the Portal Handles
| Feature | Benefit |
|---|---|
| Update payment method | Customers replace cards without contacting support |
| View invoices | Download PDF invoices for accounting |
| Upgrade or downgrade | Switch between plans you configure |
| Cancel subscription | Self-service with optional retention offers |
| Update billing info | Change name, email, tax ID |
The Customer Portal eliminates 70%+ of subscription management code in your stripe nextjs application. Configure available actions in the Stripe Dashboard under Settings > Customer Portal.
Server Components vs Client Components
The Next.js App Router's server/client component model maps naturally to Stripe's security architecture. Understanding this boundary is essential for a secure stripe nextjs integration.
What Goes Where
| Operation | Component Type | Why |
|---|---|---|
| Create Checkout Sessions | Server (Route Handler) | Requires secret key |
| Process webhooks | Server (Route Handler) | Requires secret key + signature |
| Manage subscriptions | Server (Route Handler/Action) | Requires secret key |
| Create Portal sessions | Server (Route Handler) | Requires secret key |
| Load Stripe.js | Client Component | Browser-only API |
| Render Elements | Client Component | DOM interaction |
| Redirect to Checkout | Client Component | Browser navigation |
Server Actions for Stripe
Next.js Server Actions provide a clean way to call Stripe from Client Components without creating separate API routes:
// app/actions/stripe.ts
'use server';
import { stripe } from '@/lib/stripe';
export async function createCheckoutSession(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return session.url;
}
// components/SubscribeButton.tsx
'use client';
import { createCheckoutSession } from '@/app/actions/stripe';
export function SubscribeButton({ priceId }: { priceId: string }) {
const handleClick = async () => {
const url = await createCheckoutSession(priceId);
if (url) window.location.href = url;
};
return <button onClick={handleClick}>Subscribe</button>;
}
This pattern keeps all Stripe secret key operations on the server while providing a clean interface for Client Components.
Testing Your Stripe Integration Locally
Testing stripe webhooks locally requires the Stripe CLI. Without it, you cannot verify that your webhook handlers process events correctly.
Setting Up the Stripe CLI
- Install the Stripe CLI from stripe.com/docs/stripe-cli
- Log in with
stripe login - Forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI outputs a webhook signing secret (starting with whsec_). Use this as your STRIPE_WEBHOOK_SECRET during local development.
Test Card Numbers
| Card Number | Result |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 0002 |
Card declined |
4000 0025 0000 3155 |
Requires 3D Secure |
4000 0000 0000 9995 |
Insufficient funds |
Triggering Test Events
The Stripe CLI lets you trigger specific webhook events for testing:
# Trigger a checkout completion
stripe trigger checkout.session.completed
# Trigger a payment failure
stripe trigger invoice.payment_failed
# Trigger a subscription update
stripe trigger customer.subscription.updated
Testing Checklist for Stripe and Next.js
| Test Case | How to Verify |
|---|---|
| Successful subscription | Complete checkout with test card 4242 |
| Failed payment | Use declined card 4000 0000 0000 0002 |
| Subscription upgrade | Change plan via API or Portal |
| Subscription cancellation | Cancel via Portal, verify webhook |
| Webhook signature verification | Send invalid signature, expect 400 |
| Trial expiration | Set 1-day trial, wait for webhook |
| Customer Portal access | Create portal session, verify redirect |
Security Best Practices
A production stripe nextjs integration requires careful attention to security. Payment processing is a high-value target, and mistakes can expose customer data or enable fraud.
Environment Variable Security
- Never commit API keys. Add
.env.localto.gitignore. - Use restricted API keys in production. Create keys with only the permissions your application needs.
- Rotate keys periodically. Stripe lets you roll API keys without downtime.
Webhook Security
- Verify every signature. Use
stripe.webhooks.constructEvent()on every incoming webhook. - Process idempotently. Use the event
idto prevent duplicate processing. - Use HTTPS only. Stripe will not deliver webhooks to HTTP endpoints in production.
Content Security Policy
Add Stripe's domains to your Content Security Policy:
script-src 'self' js.stripe.com;
frame-src js.stripe.com hooks.stripe.com;
connect-src api.stripe.com;
Stripe Radar for Fraud Prevention
Enable Stripe Radar to block fraudulent transactions. Radar uses machine learning trained on data from millions of Stripe merchants to identify and block fraud in real time. It is included with every Stripe account at no additional cost for the basic tier.
Security Checklist
| Item | Priority |
|---|---|
| Webhook signatures verified on every event | Required |
| API keys stored in environment variables only | Required |
| Restricted API keys in production | Recommended |
| Stripe.js loaded from js.stripe.com (never self-hosted) | Required |
| Customer card data never touches your servers | Required |
| CSP headers include Stripe domains | Recommended |
| Idempotency keys used on create/update calls | Recommended |
| Stripe Radar enabled | Recommended |
For a comprehensive security checklist covering your entire SaaS stack, see our SaaS security checklist.
Error Handling Patterns
Robust error handling separates a prototype from a production stripe nextjs application. Stripe errors come in predictable categories that you can handle gracefully.
Server-Side Error Handling
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const session = await stripe.checkout.sessions.create({
// ... session config
});
return NextResponse.json({ url: session.url });
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
return NextResponse.json(
{ error: 'Card was declined.' },
{ status: 400 }
);
}
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
return NextResponse.json(
{ error: 'Invalid request parameters.' },
{ status: 400 }
);
}
if (error instanceof Stripe.errors.StripeAuthenticationError) {
console.error('Stripe API key is invalid');
return NextResponse.json(
{ error: 'Payment service configuration error.' },
{ status: 500 }
);
}
return NextResponse.json(
{ error: 'An unexpected error occurred.' },
{ status: 500 }
);
}
}
Client-Side Error Handling
When using Stripe Elements, handle errors from confirmPayment:
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/success`,
},
redirect: 'if_required',
});
if (error) {
switch (error.type) {
case 'card_error':
setMessage(error.message || 'Your card was declined.');
break;
case 'validation_error':
setMessage('Please check your payment details.');
break;
default:
setMessage('Something went wrong. Please try again.');
}
}
Common Stripe Errors in Next.js
| Error | Cause | Solution |
|---|---|---|
| Webhook signature mismatch | Wrong signing secret or body parsing | Use req.text() for raw body |
resource_missing |
Invalid Price or Customer ID | Verify IDs exist in Stripe Dashboard |
| Rate limit exceeded | Too many API calls | Implement backoff and caching |
| Timeout on Vercel | Webhook handler too slow | Process asynchronously with a job queue |
| Hydration mismatch | Stripe Elements in Server Component | Wrap in Client Component with use client |
SaaS Starter Templates with Stripe
Building a complete stripe nextjs integration from scratch takes 1 to 2 weeks. SaaS starter templates eliminate the bulk of this work by providing pre-configured billing infrastructure.
What a Good Template Includes
| Feature | Why It Matters |
|---|---|
| Checkout Session creation | Handles plan selection and payment |
| Webhook processing | Syncs subscription state to database |
| Customer Portal integration | Self-service subscription management |
| Subscription state management | Access control based on plan |
| Pricing page component | Displays plans with monthly/annual toggle |
| Free trial support | Trial period and conversion tracking |
Recommended Approaches
Forge generates complete Next.js SaaS applications with Stripe billing pre-configured. Describe your product and pricing model, and Forge produces production-ready code with Checkout Sessions, webhook handlers, and Customer Portal integration. You own the code and deploy anywhere.
| Approach | Time to Launch | Cost | Flexibility |
|---|---|---|---|
| Custom build | 1-2 weeks | Engineer salary | Maximum |
| SaaS template | 1-2 days | $99-299 one-time | High |
| AI-generated (Forge) | Hours | Starting at $20/mo | High |
| No-code platform | Days | $25-119/mo | Limited |
For a detailed comparison of template options, see our best Next.js SaaS templates guide and best Next.js subscription templates. If you prefer a pre-built kit, our best SaaS starter kits guide ranks the top options.
Common Mistakes and How to Avoid Them
These are the most frequent stripe nextjs integration mistakes based on common developer issues.
1. Parsing the Webhook Body as JSON
The mistake: Using req.json() in the webhook handler instead of req.text().
Why it breaks: Stripe webhook signature verification requires the raw request body as a string. Parsing it as JSON first changes the string representation and causes signature verification to fail every time.
The fix: Always use const body = await req.text() in your webhook Route Handler.
2. Exposing the Secret Key to the Client
The mistake: Using NEXT_PUBLIC_STRIPE_SECRET_KEY or importing the server Stripe instance in a Client Component.
Why it breaks: The secret key gets bundled into client-side JavaScript, visible to anyone who views the page source. This gives attackers full access to your Stripe account.
The fix: Only use NEXT_PUBLIC_ for the publishable key. Keep the secret key in server-side code only.
3. Not Handling Subscription State Changes via Webhooks
The mistake: Only checking subscription status during checkout and assuming it stays active.
Why it breaks: Subscriptions can be canceled, paused, or fail payment renewal at any time. Without webhook handlers, your application shows active access to users who are no longer paying.
The fix: Implement webhook handlers for customer.subscription.updated and customer.subscription.deleted. Sync subscription state to your database on every change.
4. Building Custom Subscription Management UI
The mistake: Building pages for plan changes, payment method updates, invoice history, and cancellation from scratch.
Why it breaks: It takes weeks to build and maintain, and it is never as good as what Stripe provides. Every Stripe feature update requires changes to your custom UI.
The fix: Use the Stripe Customer Portal. It handles 90% of subscription management needs with zero custom code.
5. Running Webhook Handlers on Edge Runtime
The mistake: Deploying webhook handlers to Vercel Edge Functions.
Why it breaks: The stripe Node.js SDK requires the Node.js runtime. Edge Functions have limited Node.js API support and may not handle webhook signature verification correctly.
The fix: Add export const runtime = 'nodejs' to your webhook Route Handler file or use the default Node.js runtime.
Conclusion
A stripe nextjs integration follows a clear pattern: set up the server SDK, create Checkout Sessions for payment collection, process webhooks for state synchronization, and use the Customer Portal for subscription management. The architecture maps naturally to Next.js App Router's server/client component model.
Start with Stripe Checkout. It handles PCI compliance, mobile optimization, and payment collection better than any custom form. Add Stripe Elements later only if Checkout cannot support your specific flow.
Get webhooks right. Verify signatures, process idempotently, and handle the essential events: checkout.session.completed, invoice.paid, invoice.payment_failed, and customer.subscription.updated. This covers 90% of billing scenarios.
Use the Customer Portal. It eliminates weeks of UI development for subscription management, payment updates, and invoice access.
Save time with templates. Use Forge or a SaaS starter template to skip the boilerplate. The patterns are well-established, and there is no reason to build them from scratch for most projects.
For the full SaaS billing architecture that complements this stripe nextjs guide, see our SaaS Stripe integration guide and SaaS billing system guide. If you are choosing between payment platforms, our Stripe vs Paddle and Stripe vs Lemon Squeezy comparisons cover the tradeoffs. And if you are building your first SaaS from scratch, our how to build a SaaS guide covers the complete Next.js and Supabase stack.
Related Resources
- SaaS Stripe Integration: Billing Made Simple
- How to Build a SaaS Billing System: Complete Guide
- Stripe vs Paddle for SaaS: Payments Compared
- Stripe vs Lemon Squeezy: Which Payment Platform?
- SaaS Authentication: Complete Implementation Guide
- SaaS Pricing Page Design: Convert More Customers
- Best Next.js SaaS Templates: 12 Boilerplates Ranked
- Best Next.js Subscription Templates
- Best SaaS Starter Kits
- How to Build a SaaS: The Next.js and Supabase Stack
Frequently Asked Questions
-
Create a Route Handler at app/api/webhooks/stripe/route.ts. Read the raw request body using req.text(), then verify the webhook signature with stripe.webhooks.constructEvent() using your STRIPE_WEBHOOK_SECRET. Always return a 200 response quickly and process events asynchronously if the logic takes more than a few seconds. For local development, use the Stripe CLI with stripe listen to forward events to localhost:3000/api/webhooks/stripe. The essential events for SaaS are checkout.session.completed, invoice.paid, invoice.payment_failed, and customer.subscription.updated.
-
Use Stripe Checkout for most Next.js SaaS applications. Checkout is a hosted payment page that handles PCI compliance, mobile responsiveness, localization, tax calculation, and payment method display automatically. Stripe reports that Checkout converts up to 20% better than custom forms because they optimize the experience continuously. Use Stripe Elements only when you need a deeply embedded checkout flow with custom branding that cannot be achieved with Checkout, such as inline payment forms within multi-step onboarding flows.
-
Use Stripe test mode keys (starting with pk_test_ and sk_test_) in your environment variables. Install the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhooks/stripe to forward webhook events to your local development server. Use test card numbers like 4242 4242 4242 4242 for successful payments and 4000 0000 0000 0002 for declined cards. The Stripe CLI also lets you trigger specific events with stripe trigger checkout.session.completed to test your webhook handlers without completing a real checkout flow.
-
Install three packages: stripe (the Node.js server SDK for API calls in Route Handlers and Server Actions), @stripe/stripe-js (the client-side loader for Stripe.js), and @stripe/react-stripe-js (React components for Stripe Elements). For most projects using Stripe Checkout, you only need stripe and @stripe/stripe-js since Elements are optional. Keep packages updated to the latest versions to access new Stripe API features and security patches.
-
Create subscriptions through Stripe Checkout Sessions with mode set to subscription. Handle the subscription lifecycle via webhooks: listen for customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted events to sync subscription state to your database. Use stripe.subscriptions.update() for plan changes with proration, and stripe.subscriptions.update() with cancel_at_period_end set to true for cancellations. Set up the Stripe Customer Portal so users can manage billing, update payment methods, and switch plans without you building a custom UI.
-
Keep all Stripe secret key operations in Server Components, Server Actions, or Route Handlers. This includes creating Checkout Sessions, managing subscriptions, and processing webhooks. Client Components (marked with use client) handle Stripe.js initialization with the publishable key and Stripe Elements rendering. Never expose your Stripe secret key to the client. Pass only the Checkout Session URL or client_secret from server to client when needed for payment confirmation.
-
Stripe charges 2.9% plus $0.30 per successful card payment for standard processing. Stripe Billing adds 0.7% per recurring transaction for subscription management features. Stripe Tax adds 0.5% per transaction for automatic tax calculation. For a SaaS product charging $50 per month per customer, total Stripe fees are approximately $2.35 per transaction (about 4.7% effective rate). There are no Next.js-specific costs. Volume discounts are available for businesses processing over $100K per month.
-
Yes. Next.js with Supabase and Stripe is the most popular SaaS stack in 2026. Use Next.js App Router for the frontend and API routes, Supabase for the database and authentication, and Stripe for billing. Create Stripe Checkout Sessions in Next.js Route Handlers, sync subscription data to Supabase via webhooks, and enforce access control with Supabase Row Level Security based on subscription status. Several SaaS starter templates including Forge come with this stack pre-configured.
Join 50k+ subscribers
Web dev, SaaS, growth & marketing. Weekly.
Keep Learning
More articles you might find interesting.