Integrate Stripe Payments in a Next.js Application
A complete guide to integrating Stripe payments in Next.js, covering payment intents, Stripe Elements, webhooks, subscriptions, and testing.
Tags
Integrate Stripe Payments in a Next.js Application
In this tutorial, you will build a complete payment integration using Stripe and Next.js. You will set up the Stripe SDK, create payment intents on the server, collect card details securely with Stripe Elements, handle webhooks for reliable order fulfillment, implement basic subscription billing, and test everything using the Stripe CLI.
Payment processing is one of the most critical features in any e-commerce or SaaS application. Getting it wrong can mean lost revenue, security vulnerabilities, or a poor user experience. Stripe's API is designed to handle the complexity of payments while keeping your application's PCI compliance burden minimal.
TL;DR
Create a payment intent on your Next.js server, pass the client secret to Stripe Elements on the frontend, confirm the payment, and listen for webhook events to fulfill orders. For subscriptions, create a Stripe Customer and attach a recurring Price. Test everything locally with the Stripe CLI before deploying.
Prerequisites
- ›Next.js 14+ with the App Router
- ›A Stripe account (free to create at stripe.com)
- ›Node.js 18+
- ›The Stripe CLI installed for webhook testing
Install the required packages:
npm install stripe @stripe/stripe-js @stripe/react-stripe-jsStep 1: Configure Stripe API Keys
Add your Stripe keys to your environment variables. Never expose your secret key to the client.
# .env.local
STRIPE_SECRET_KEY=sk_test_your_test_secret_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_test_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secretCreate a server-side Stripe instance:
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
typescript: true,
});Create a client-side Stripe loader:
// lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);The separation between server and client Stripe instances is critical. The server instance uses your secret key and can create charges, manage customers, and access sensitive data. The client instance only has the publishable key and is limited to tokenizing card details.
Step 2: Create a Payment Intent API Route
Payment Intents are the core of Stripe's payment flow. You create one on the server, and the client uses the returned clientSecret to confirm the payment.
// app/api/create-payment-intent/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { z } from "zod";
const paymentSchema = z.object({
amount: z.number().min(50, "Minimum amount is $0.50"),
currency: z.string().default("usd"),
metadata: z.record(z.string()).optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { amount, currency, metadata } = paymentSchema.parse(body);
const paymentIntent = await stripe.paymentIntents.create({
amount, // Amount in cents
currency,
automatic_payment_methods: {
enabled: true,
},
metadata: {
...metadata,
source: "next-app",
},
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request", details: error.errors },
{ status: 400 }
);
}
console.error("Payment intent creation failed:", error);
return NextResponse.json(
{ error: "Failed to create payment intent" },
{ status: 500 }
);
}
}The automatic_payment_methods option enables all payment methods you have configured in your Stripe Dashboard, including cards, Apple Pay, Google Pay, and bank transfers. The metadata field lets you attach custom data like order IDs or user IDs to the payment for later reference.
Step 3: Build the Payment Form with Stripe Elements
Stripe Elements provides pre-built, secure UI components for collecting payment details. Card data is entered into Stripe-hosted iframes, so sensitive information never touches your server.
// components/checkout/CheckoutForm.tsx
"use client";
import { useState } from "react";
import {
PaymentElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
interface CheckoutFormProps {
amount: number;
}
export function CheckoutForm({ amount }: CheckoutFormProps) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const [succeeded, setSucceeded] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message ?? "An error occurred");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success`,
},
redirect: "if_required",
});
if (confirmError) {
setError(confirmError.message ?? "Payment failed");
setProcessing(false);
} else {
setSucceeded(true);
setProcessing(false);
}
}
if (succeeded) {
return (
<div className="rounded-lg border border-green-600 bg-green-900/20 p-6 text-center">
<h3 className="text-lg font-semibold text-green-400">Payment Successful</h3>
<p className="mt-2 text-gray-400">
Your payment of ${(amount / 100).toFixed(2)} has been processed.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border border-gray-700 bg-gray-800 p-4">
<PaymentElement
options={{
layout: "tabs",
}}
/>
</div>
{error && (
<div className="rounded-lg border border-red-600 bg-red-900/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={!stripe || processing}
className="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{processing ? "Processing..." : `Pay $${(amount / 100).toFixed(2)}`}
</button>
</form>
);
}The PaymentElement component renders a complete payment form that adapts to the payment methods available. The redirect: "if_required" option handles 3D Secure authentication, which redirects the user to their bank's verification page and then back to your return_url.
Step 4: Create the Checkout Page
Wrap the checkout form with the Stripe Elements provider and fetch the client secret.
// app/checkout/page.tsx
"use client";
import { useState, useEffect } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { CheckoutForm } from "@/components/checkout/CheckoutForm";
const AMOUNT = 2999; // $29.99 in cents
export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function createPaymentIntent() {
try {
const response = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: AMOUNT }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error);
return;
}
setClientSecret(data.clientSecret);
} catch {
setError("Failed to initialize checkout");
}
}
createPaymentIntent();
}, []);
if (error) {
return (
<div className="mx-auto max-w-md p-8">
<div className="rounded-lg border border-red-600 bg-red-900/20 p-6">
<p className="text-red-400">{error}</p>
</div>
</div>
);
}
if (!clientSecret) {
return (
<div className="mx-auto max-w-md p-8">
<div className="animate-pulse space-y-4">
<div className="h-12 rounded bg-gray-800" />
<div className="h-12 rounded bg-gray-800" />
<div className="h-12 rounded bg-gray-800" />
</div>
</div>
);
}
return (
<div className="mx-auto max-w-md p-8">
<h1 className="mb-6 text-2xl font-bold text-white">Checkout</h1>
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800 p-4">
<p className="text-gray-400">Order Total</p>
<p className="text-2xl font-bold text-white">
${(AMOUNT / 100).toFixed(2)}
</p>
</div>
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "night",
variables: {
colorPrimary: "#3B82F6",
borderRadius: "8px",
},
},
}}
>
<CheckoutForm amount={AMOUNT} />
</Elements>
</div>
);
}The Elements provider accepts an appearance option that lets you theme the Stripe components to match your application's design. The night theme works well with dark UIs.
Step 5: Handle Webhooks for Order Fulfillment
Webhooks are the reliable way to know when a payment succeeds. Do not rely on the client-side confirmation alone because the user might close the browser before your success handler runs.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import Stripe from "stripe";
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
switch (event.type) {
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment succeeded: ${paymentIntent.id}`);
// Fulfill the order
// await fulfillOrder(paymentIntent.metadata.orderId);
// await sendConfirmationEmail(paymentIntent.receipt_email);
break;
}
case "payment_intent.payment_failed": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment failed: ${paymentIntent.id}`);
// Notify the user
// await notifyPaymentFailure(paymentIntent.metadata.userId);
break;
}
case "customer.subscription.created": {
const subscription = event.data.object as Stripe.Subscription;
console.log(`Subscription created: ${subscription.id}`);
// Activate the subscription in your database
// await activateSubscription(subscription.metadata.userId, subscription.id);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
console.log(`Subscription canceled: ${subscription.id}`);
// Deactivate access
// await deactivateSubscription(subscription.id);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
console.log(`Invoice payment failed: ${invoice.id}`);
// Handle failed subscription payment
// await handleFailedInvoice(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Always return 200 to acknowledge receipt
return NextResponse.json({ received: true });
}Key points about webhooks: you must verify the signature to ensure the event genuinely came from Stripe. Always return a 200 status code promptly, even if your processing fails internally, to prevent Stripe from retrying unnecessarily. Handle your business logic asynchronously if it might take longer than a few seconds.
Important: the raw request body must be read as text, not parsed as JSON, because the signature verification requires the exact bytes Stripe sent.
Step 6: Implement Subscription Billing
For SaaS applications, subscriptions are the most common billing model. Here is how to create a customer and subscribe them to a recurring price.
// app/api/create-subscription/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(request: NextRequest) {
try {
const { email, paymentMethodId, priceId, userId } = await request.json();
// Create or retrieve the Stripe customer
const customers = await stripe.customers.list({ email, limit: 1 });
let customer: { id: string };
if (customers.data.length > 0) {
customer = customers.data[0];
} else {
customer = await stripe.customers.create({
email,
payment_method: paymentMethodId,
invoice_settings: {
default_payment_method: paymentMethodId,
},
metadata: { userId },
});
}
// Create the subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
payment_settings: {
payment_method_types: ["card"],
save_default_payment_method: "on_subscription",
},
expand: ["latest_invoice.payment_intent"],
metadata: { userId },
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
return NextResponse.json({
subscriptionId: subscription.id,
clientSecret: paymentIntent.client_secret,
status: subscription.status,
});
} catch (error) {
console.error("Subscription creation failed:", error);
return NextResponse.json(
{ error: "Failed to create subscription" },
{ status: 500 }
);
}
}Subscriptions automatically generate invoices and attempt to charge the customer's default payment method on each billing cycle. If a charge fails, Stripe retries according to your Smart Retries settings and emits webhook events so you can notify the user.
Step 7: Handle Errors Gracefully
Payment errors are common and expected. Build a robust error handling layer.
// lib/stripe-errors.ts
export function getStripeErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
card_declined: "Your card was declined. Please try a different card.",
expired_card: "Your card has expired. Please use a different card.",
incorrect_cvc: "The CVC number is incorrect. Please check and try again.",
insufficient_funds: "Insufficient funds. Please try a different card.",
processing_error: "A processing error occurred. Please try again.",
incorrect_number: "The card number is incorrect. Please check and try again.",
};
return errorMessages[errorCode] || "An unexpected error occurred. Please try again.";
}Map Stripe error codes to user-friendly messages. The raw error codes from Stripe are machine-readable but not suitable for displaying directly to users. Always log the full error server-side for debugging while showing a friendly message to the user.
Step 8: Test with the Stripe CLI
The Stripe CLI lets you trigger webhook events locally and simulate the full payment lifecycle.
# Install the Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger a test event
stripe trigger payment_intent.succeededUse these test card numbers during development:
- ›4242 4242 4242 4242 - Successful payment
- ›4000 0000 0000 3220 - Requires 3D Secure authentication
- ›4000 0000 0000 9995 - Declined (insufficient funds)
- ›4000 0000 0000 0002 - Declined (generic decline)
Always use an expiry date in the future (e.g., 12/34) and any three-digit CVC.
Summary
The complete Stripe integration consists of these layers:
- ›Server-side Stripe instance for creating payment intents and managing customers
- ›Payment Intent API route that generates client secrets for each transaction
- ›Stripe Elements on the client for secure, PCI-compliant card collection
- ›Webhook handler for reliable order fulfillment and subscription lifecycle management
- ›Subscription API for recurring billing with automatic retries
- ›Error handling that translates Stripe error codes into user-friendly messages
The webhook-based architecture is essential because client-side payment confirmation is unreliable. The user might close the browser, lose connectivity, or encounter a redirect during 3D Secure. Webhooks guarantee that your server is notified of every payment outcome.
Next Steps
- ›Add a customer portal using Stripe's hosted billing portal for subscription management
- ›Implement usage-based billing with metered subscriptions for API products
- ›Add coupon and promotion codes to your checkout flow
- ›Build an admin dashboard that displays payment analytics using Stripe's reporting API
- ›Implement refund handling with partial and full refund support
- ›Add invoice PDF generation for business customers who need receipts
FAQ
Is Stripe free to use during development?
Yes. Stripe provides a test mode with test API keys that simulate the full payment flow without processing real charges. You can use test card numbers like 4242 4242 4242 4242 to simulate successful payments. No fees are charged until you process live transactions.
How do I handle failed payments in production?
Listen for the payment_intent.payment_failed webhook event. Notify the user via email or an in-app notification, and provide a way to retry the payment with a different payment method. For subscriptions, Stripe automatically retries failed payments according to your Smart Retries configuration in the Dashboard.
Can I use Stripe Checkout instead of building a custom form?
Yes. Stripe Checkout is a hosted payment page that handles the entire payment UI, including mobile optimization, localization, and payment method selection. It is faster to implement but offers less customization than Stripe Elements. Use Checkout when you want to launch quickly and do not need a fully custom-branded experience.
Do I need PCI compliance to use Stripe Elements?
Stripe Elements renders card input fields in Stripe-hosted iframes, so sensitive card details never touch your server. This reduces your PCI compliance scope to SAQ A, the simplest self-assessment level. You still need to serve your pages over HTTPS and follow basic security practices.
How do I test webhooks locally?
Install the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhooks/stripe. This creates a local tunnel that forwards Stripe webhook events to your development server in real time. The CLI provides a webhook signing secret to use in your .env.local file.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.