Stripe + Next.js — Complete Payment Integration Guide 2026

Prerequisites
Before starting this tutorial, make sure you have:
- Node.js 20+ installed
- Next.js 15 experience (App Router)
- A Stripe account (free to create at stripe.com)
- Basic knowledge of TypeScript and React
- A code editor (VS Code recommended)
You do not need a business entity to test payments — Stripe provides a full test mode with simulated cards.
What You'll Build
By the end of this tutorial, you will have a complete payment system with:
- One-time payments via Stripe Checkout
- Recurring subscriptions with multiple pricing tiers
- Webhook handling for real-time payment events
- Customer portal for self-service subscription management
- Server-side verification of payment status
- Type-safe API routes using Next.js App Router
Step 1: Project Setup
Create a new Next.js project and install dependencies:
npx create-next-app@latest stripe-payments --typescript --tailwind --app --src-dir
cd stripe-paymentsInstall the Stripe packages:
npm install stripe @stripe/stripe-jsstripe— Server-side Stripe SDK for Node.js@stripe/stripe-js— Client-side Stripe.js loader
Step 2: Configure Environment Variables
Create a .env.local file at the project root:
# Stripe keys (use test keys during development)
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# App URL
NEXT_PUBLIC_APP_URL=http://localhost:3000Find your keys in the Stripe Dashboard under Developers → API keys. Always use sk_test_ and pk_test_ prefixed keys for development.
Important: Never expose your secret key on the client side. Only NEXT_PUBLIC_ prefixed variables are available in the browser.
Step 3: Initialize the Stripe Client
Create a shared Stripe instance for server-side usage:
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-12-18.acacia",
typescript: true,
});Create a client-side loader:
// src/lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";
let stripePromise: ReturnType<typeof loadStripe>;
export function getStripe() {
if (!stripePromise) {
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
}
return stripePromise;
}Step 4: Create Products and Prices in Stripe
You can create products via the Stripe Dashboard or programmatically. For this tutorial, create them via the Dashboard:
- Go to Products in your Stripe Dashboard
- Click Add product
- Create a product called "Pro Plan" with two prices:
- Monthly: $19/month (recurring)
- Yearly: $190/year (recurring)
- Create another product called "Enterprise Plan":
- Monthly: $49/month (recurring)
- Yearly: $490/year (recurring)
Note down the Price IDs (they start with price_). Add them to your environment:
# Add to .env.local
STRIPE_PRO_MONTHLY_PRICE_ID=price_...
STRIPE_PRO_YEARLY_PRICE_ID=price_...
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_...
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_...Step 5: Build the Pricing Page
Create a pricing component that displays your plans:
// src/app/pricing/page.tsx
import { CheckoutButton } from "@/components/checkout-button";
const plans = [
{
name: "Pro",
description: "For growing teams",
monthlyPrice: 19,
yearlyPrice: 190,
monthlyPriceId: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
yearlyPriceId: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
features: [
"Unlimited projects",
"Priority support",
"Advanced analytics",
"API access",
],
},
{
name: "Enterprise",
description: "For large organizations",
monthlyPrice: 49,
yearlyPrice: 490,
monthlyPriceId: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID!,
yearlyPriceId: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID!,
features: [
"Everything in Pro",
"SSO authentication",
"Custom integrations",
"Dedicated support",
"SLA guarantee",
],
},
];
export default function PricingPage() {
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<h1 className="text-4xl font-bold text-center mb-4">
Simple, Transparent Pricing
</h1>
<p className="text-gray-600 text-center mb-12">
Choose the plan that fits your needs
</p>
<div className="grid md:grid-cols-2 gap-8">
{plans.map((plan) => (
<div
key={plan.name}
className="border rounded-2xl p-8 shadow-sm hover:shadow-md transition"
>
<h2 className="text-2xl font-bold">{plan.name}</h2>
<p className="text-gray-500 mt-2">{plan.description}</p>
<p className="text-4xl font-bold mt-6">
${plan.monthlyPrice}
<span className="text-base font-normal text-gray-500">
/month
</span>
</p>
<ul className="mt-6 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="text-green-500">✓</span>
{feature}
</li>
))}
</ul>
<CheckoutButton priceId={plan.monthlyPriceId} planName={plan.name} />
</div>
))}
</div>
</div>
);
}Step 6: Create the Checkout API Route
Build the server-side API route that creates a Stripe Checkout Session:
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(req: NextRequest) {
try {
const { priceId, mode = "subscription" } = await req.json();
if (!priceId) {
return NextResponse.json(
{ error: "Price ID is required" },
{ status: 400 }
);
}
const session = await stripe.checkout.sessions.create({
mode: mode as "subscription" | "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`,
// Automatically collect billing address
billing_address_collection: "required",
// Allow promotion codes
allow_promotion_codes: true,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}Step 7: Build the Checkout Button Component
Create the client component that redirects users to Stripe Checkout:
// src/components/checkout-button.tsx
"use client";
import { useState } from "react";
interface CheckoutButtonProps {
priceId: string;
planName: string;
}
export function CheckoutButton({ priceId, planName }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
async function handleCheckout() {
setLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error(data.error || "Failed to create checkout session");
}
} catch (error) {
console.error("Checkout error:", error);
alert("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleCheckout}
disabled={loading}
className="w-full mt-8 bg-black text-white py-3 rounded-lg font-medium
hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
transition"
>
{loading ? "Redirecting..." : `Subscribe to ${planName}`}
</button>
);
}Step 8: Handle Webhooks
Webhooks are critical — they notify your app when payments succeed, subscriptions renew, or payments fail. This is the backbone of a reliable payment system.
// src/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(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
// Provision access for the customer
await handleCheckoutComplete(session);
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
// Subscription renewed successfully
await handleInvoicePaid(invoice);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
// Payment failed — notify the customer
await handlePaymentFailed(invoice);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
// Subscription cancelled — revoke access
await handleSubscriptionCancelled(subscription);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
// Plan changed or subscription updated
await handleSubscriptionUpdated(subscription);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook handler error:", error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
console.log(
`Checkout complete for customer ${customerId}, subscription ${subscriptionId}`
);
// TODO: Save to your database
// await db.user.update({
// where: { stripeCustomerId: customerId },
// data: { subscriptionId, subscriptionStatus: "active" },
// });
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
console.log(`Invoice paid for customer ${customerId}`);
// TODO: Update subscription period in your database
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
console.log(`Payment failed for customer ${customerId}`);
// TODO: Send notification email to customer
}
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
console.log(`Subscription cancelled for customer ${customerId}`);
// TODO: Revoke access in your database
// await db.user.update({
// where: { stripeCustomerId: customerId },
// data: { subscriptionStatus: "cancelled" },
// });
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const status = subscription.status;
console.log(
`Subscription updated for customer ${customerId}: ${status}`
);
// TODO: Update plan details in your database
}Important: The webhook route reads the raw body as text (not JSON) because Stripe needs the raw payload for signature verification.
Step 9: Set Up Local Webhook Testing
Install the Stripe CLI to test webhooks locally:
# macOS
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/stripeThe CLI will output a webhook signing secret (whsec_...). Copy it to your .env.local as STRIPE_WEBHOOK_SECRET.
Now when you complete a test checkout, events will be forwarded to your local webhook handler.
Step 10: Build the Success Page
Create a page that confirms the payment and shows subscription details:
// src/app/success/page.tsx
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import Link from "next/link";
interface SuccessPageProps {
searchParams: Promise<{ session_id?: string }>;
}
export default async function SuccessPage({ searchParams }: SuccessPageProps) {
const { session_id } = await searchParams;
if (!session_id) {
redirect("/pricing");
}
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["subscription", "line_items"],
});
if (session.payment_status !== "paid") {
redirect("/pricing");
}
const subscription = session.subscription as Stripe.Subscription | null;
return (
<div className="max-w-lg mx-auto py-16 px-4 text-center">
<div className="text-6xl mb-6">🎉</div>
<h1 className="text-3xl font-bold mb-4">Payment Successful!</h1>
<p className="text-gray-600 mb-8">
Thank you for subscribing. Your account has been upgraded.
</p>
{subscription && (
<div className="bg-gray-50 rounded-lg p-6 mb-8 text-left">
<h2 className="font-semibold mb-2">Subscription Details</h2>
<p className="text-sm text-gray-600">
Status: <span className="capitalize">{subscription.status}</span>
</p>
<p className="text-sm text-gray-600">
Current period ends:{" "}
{new Date(
subscription.current_period_end * 1000
).toLocaleDateString()}
</p>
</div>
)}
<Link
href="/dashboard"
className="inline-block bg-black text-white px-6 py-3 rounded-lg
hover:bg-gray-800 transition"
>
Go to Dashboard
</Link>
</div>
);
}Step 11: Add Customer Portal for Self-Service Management
Stripe Customer Portal lets users manage their own subscriptions — upgrade, downgrade, cancel, and update payment methods.
First, configure the portal in your Stripe Dashboard under Settings → Billing → Customer portal.
Then create an API route to generate portal sessions:
// src/app/api/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(req: NextRequest) {
try {
const { customerId } = await req.json();
if (!customerId) {
return NextResponse.json(
{ error: "Customer ID is required" },
{ status: 400 }
);
}
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Portal error:", error);
return NextResponse.json(
{ error: "Failed to create portal session" },
{ status: 500 }
);
}
}Create a button component to access the portal:
// src/components/manage-subscription-button.tsx
"use client";
import { useState } from "react";
export function ManageSubscriptionButton({
customerId,
}: {
customerId: string;
}) {
const [loading, setLoading] = useState(false);
async function handleManage() {
setLoading(true);
try {
const response = await fetch("/api/portal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerId }),
});
const data = await response.json();
if (data.url) {
window.location.href = data.url;
}
} catch (error) {
console.error("Portal error:", error);
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleManage}
disabled={loading}
className="bg-gray-100 text-gray-700 px-4 py-2 rounded-lg
hover:bg-gray-200 disabled:opacity-50 transition"
>
{loading ? "Loading..." : "Manage Subscription"}
</button>
);
}Step 12: Verify Subscription Status Server-Side
Create a utility to check subscription status before granting access to premium features:
// src/lib/subscription.ts
import { stripe } from "./stripe";
export type SubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "cancelled"
| "none";
export async function getSubscriptionStatus(
customerId: string
): Promise<SubscriptionStatus> {
try {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 1,
});
if (subscriptions.data.length === 0) {
return "none";
}
const subscription = subscriptions.data[0];
switch (subscription.status) {
case "active":
return "active";
case "trialing":
return "trialing";
case "past_due":
return "past_due";
case "canceled":
return "cancelled";
default:
return "none";
}
} catch (error) {
console.error("Error fetching subscription:", error);
return "none";
}
}
export function hasActiveSubscription(status: SubscriptionStatus): boolean {
return status === "active" || status === "trialing";
}Use it in your pages to gate premium content:
// src/app/dashboard/page.tsx
import { getSubscriptionStatus, hasActiveSubscription } from "@/lib/subscription";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
// In a real app, get customerId from your auth session
const customerId = "cus_...";
const status = await getSubscriptionStatus(customerId);
if (!hasActiveSubscription(status)) {
redirect("/pricing");
}
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-gray-600 mt-2">
Welcome back! Your subscription is {status}.
</p>
{/* Premium content here */}
</div>
);
}Step 13: Handle One-Time Payments
Not everything needs a subscription. Here is how to handle one-time payments for digital products or services:
// src/app/api/checkout/one-time/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(req: NextRequest) {
try {
const { productName, amount, currency = "usd" } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency,
product_data: {
name: productName,
},
unit_amount: amount, // Amount in cents (e.g., 2999 = $29.99)
},
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 });
} catch (error) {
console.error("One-time checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout" },
{ status: 500 }
);
}
}Step 14: Test with Stripe Test Cards
Stripe provides test card numbers for different scenarios:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 3220 | Requires 3D Secure authentication |
4000 0000 0000 9995 | Payment declined |
4000 0000 0000 0341 | Attaching card fails |
Use any future expiration date, any 3-digit CVC, and any postal code.
Run your app and test the full flow:
npm run dev- Visit
http://localhost:3000/pricing - Click "Subscribe to Pro"
- Use test card
4242 4242 4242 4242 - Verify you land on the success page
- Check the Stripe Dashboard for the new subscription
Step 15: Deploy Webhook to Production
When deploying, you need to register your production webhook URL in Stripe:
- Go to Developers → Webhooks in your Stripe Dashboard
- Click Add endpoint
- Enter your production URL:
https://yourdomain.com/api/webhooks/stripe - Select events to listen to:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.deletedcustomer.subscription.updated
- Copy the new signing secret and add it to your production environment variables
Troubleshooting
Webhook signature verification fails
Make sure you are reading the raw request body as text, not parsing it as JSON before verification. Next.js App Router's req.text() handles this correctly.
Checkout session returns null URL
Check that your Stripe secret key is correct and that the price ID exists. Test mode keys only work with test mode prices.
Subscription status not updating
Ensure your webhook endpoint is accessible and returning 200 status codes. Use stripe listen locally or check the Stripe Dashboard webhook logs for failed deliveries.
CORS errors on checkout redirect
You do not need CORS headers — Stripe Checkout is a server-side redirect, not a client-side API call. Make sure you are calling your own API route, not the Stripe API directly from the browser.
Next Steps
- Add authentication — Connect Stripe customers to your user accounts using Auth.js or Better Auth
- Store data in a database — Use Drizzle ORM or Supabase to persist subscription data
- Add metered billing — Charge based on usage with Stripe's usage-based pricing
- Implement trial periods — Offer free trials before charging
- Multi-currency support — Accept payments in multiple currencies for global customers
Conclusion
You have built a complete payment system with Stripe and Next.js that handles subscriptions, one-time payments, webhooks, and self-service management through the customer portal. The webhook-driven architecture ensures your app stays in sync with Stripe regardless of where changes happen — whether from your app, the Stripe Dashboard, or the customer portal.
The key principles to remember:
- Never trust the client — always verify payment status server-side
- Webhooks are your source of truth — do not rely on redirect callbacks alone
- Test thoroughly — use Stripe test cards and the CLI to simulate every scenario
- Handle failures gracefully — payments can fail, subscriptions can lapse, cards can expire
With this foundation, you can extend your payment system to handle any billing model your application requires.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.

Build Transactional Emails with Resend and React Email in Next.js
Learn how to build beautiful, type-safe transactional emails using React Email and Resend in a Next.js application. This tutorial covers email template design, preview workflows, sending via API routes, and production deployment.