DEV Community

Mahmudul Hasan
Mahmudul Hasan

Posted on

Integrating Stripe Payments & Webhooks in Next.js 16 + MongoDB: The Complete Step-by-Step Guide

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 ]
Enter fullscreen mode Exit fullscreen mode

2. Step 1: Install Dependencies & Setup Environment

First, install the official Stripe Node.js SDK:

npm install stripe
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

6. Testing Webhooks Locally with Stripe CLI

To test webhooks on your localhost:3000 without deploying to production, use the official Stripe CLI:

  1. Install Stripe CLI and log in:
   stripe login
Enter fullscreen mode Exit fullscreen mode
  1. Forward events to your Next.js local endpoint:
   stripe listen --forward-to localhost:3000/api/stripe/webhook
Enter fullscreen mode Exit fullscreen mode
  1. Copy the whsec_... secret outputted by the CLI and paste it as STRIPE_WEBHOOK_SECRET in .env.local.
  2. Trigger a test checkout in another terminal:
   stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

7. Key Best Practices for Production

  1. Raw Body in Next.js: Stripe signature validation needs the exact unparsed string buffer (await req.text()). Never parse it with await req.json() before verifying the signature.
  2. Metadata is Key: Always pass your internal database userId inside metadata during session creation so your webhook knows who to upgrade.
  3. Handle Cancellations: Listen to customer.subscription.deleted to 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)