Handling payments is one of the most critical features of any modern SaaS or e-commerce web application. Whether you are charging a one-time fee or recurring subscriptions, Stripe provides the most developer-friendly and secure payment infrastructure.
In this tutorial, we will build an end-to-end payment workflow using Next.js 16 (App Router), Stripe Checkout, Webhooks, and MongoDB.
1. How the Stripe Payment Flow Works
Instead of handling sensitive credit card numbers directly on your server (which requires complex PCI compliance), Stripe uses a secure 3-step lifecycle:
[ User Clicks "Buy / Subscribe" ]
│
▼
[ Next.js 16 Server: Creates Stripe Checkout Session ]
│
▼
[ User Redirected to Secure Stripe Checkout Page ]
│ (User enters payment details)
▼
[ Stripe Fires "checkout.session.completed" Webhook Event ]
│
▼
[ Next.js Webhook Route: Verifies Signature & Updates MongoDB ]
2. Step 1: Install Dependencies & Setup Environment
First, install the official Stripe Node.js SDK:
npm install stripe
Add your Stripe API keys to your .env.local:
STRIPE_SECRET_KEY=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Tip: You can obtain your test keys from the Stripe Developer Dashboard (https://dashboard.stripe.com/test/apikeys).
3. Step 2: Create Stripe Checkout API Route
When a user clicks "Upgrade to Pro", our frontend sends a request to create a Checkout Session.
Create src/app/api/stripe/checkout/route.js:
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
try {
const { userId, userEmail, priceId } = await req.json();
if (!userId || !userEmail) {
return NextResponse.json(
{ error: 'User must be authenticated' },
{ status: 401 }
);
}
// Create a secure Stripe Checkout Session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'subscription', // or 'payment' for one-time purchases
customer_email: userEmail,
line_items: [
{
price: priceId || 'price_1P...', // Your Stripe Price ID
quantity: 1,
},
],
// Pass internal userId in metadata so webhook can identify the user
metadata: {
userId: userId,
},
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?payment=success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?payment=cancelled`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Stripe Checkout Error:', error);
return NextResponse.json(
{ error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}
4. Step 3: Frontend Checkout Button (React / Next.js)
In your pricing page or upgrade modal, call the checkout route and redirect the user:
'use client';
import { useState } from 'react';
import { CreditCard, Loader2 } from 'lucide-react';
export default function UpgradeButton({ userId, userEmail, priceId }) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, userEmail, priceId }),
});
const data = await res.json();
if (data.url) {
// Redirect user to Stripe's hosted checkout page
window.location.href = data.url;
}
} catch (err) {
console.error('Checkout failed:', err);
alert('Unable to initiate checkout. Please try again.');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="px-6 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold flex items-center gap-2 transition-all disabled:opacity-50"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : <CreditCard size={18} />}
<span>Upgrade to Pro</span>
</button>
);
}
5. Step 4: Building the Webhook Handler in Next.js 16
Never fulfill orders on the frontend success redirect URL. Users can close their browser before redirecting, or malicious actors could fake a visit to the success URL.
Webhooks are cryptographic server-to-server notifications from Stripe directly to your backend.
Create src/app/api/stripe/webhook/route.js:
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { MongoClient, ObjectId } from 'mongodb';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const mongoUri = process.env.MONGODB_URI;
export async function POST(req) {
const body = await req.text(); // Next.js 16 raw body for signature check
const signature = req.headers.get('stripe-signature');
let event;
// 1. Verify Stripe Cryptographic Signature
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('⚠️ Webhook signature verification failed:', err.message);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// 2. Handle the Successful Payment Event
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const userId = session.metadata?.userId;
const customerId = session.customer;
const subscriptionId = session.subscription;
if (userId) {
const client = new MongoClient(mongoUri);
try {
await client.connect();
const db = client.db('Hasan');
// Update user subscription status in MongoDB
await db.collection('users').updateOne(
{ _id: new ObjectId(userId) },
{
$set: {
isPro: true,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
plan: 'pro',
updatedAt: new Date(),
},
}
);
console.log(`✅ Successfully upgraded User ${userId} to Pro plan.`);
} catch (dbError) {
console.error('Database update error:', dbError);
} finally {
await client.close();
}
}
}
return NextResponse.json({ received: true }, { status: 200 });
}
6. Testing Webhooks Locally with Stripe CLI
To test webhooks on your localhost:3000 without deploying to production, use the official Stripe CLI:
- Install Stripe CLI and log in:
stripe login
- Forward events to your Next.js local endpoint:
stripe listen --forward-to localhost:3000/api/stripe/webhook
- Copy the
whsec_...secret outputted by the CLI and paste it asSTRIPE_WEBHOOK_SECRETin.env.local. - Trigger a test checkout in another terminal:
stripe trigger checkout.session.completed
7. Key Best Practices for Production
-
Raw Body in Next.js: Stripe signature validation needs the exact unparsed string buffer (
await req.text()). Never parse it withawait req.json()before verifying the signature. -
Metadata is Key: Always pass your internal database
userIdinsidemetadataduring session creation so your webhook knows who to upgrade. -
Handle Cancellations: Listen to
customer.subscription.deletedto downgrade users when their subscription expires.
Conclusion
With Next.js 16, Stripe Checkout, and MongoDB, you have a complete, secure, and production-ready payment flow that handles transactions safely.
Written by Mahmudul Hasan Full-Stack Developer & AI Integration Specialist.
Portfolio: https://mahmudulhasan-dev.vercel.app
Top comments (0)