Skip to main content
Back to Blog
Tutorials
4 min read
December 20, 2024

How to Build a Payment Processing System with Stripe in Next.js

Build a complete payment system with Stripe in Next.js — one-time payments, subscriptions, customer portal, webhooks, and invoicing.

Ryel Banfield

Founder & Lead Developer

Stripe is the standard for online payments. Here is how to build a full payment flow in Next.js.

Setup

pnpm add stripe @stripe/stripe-js @stripe/react-stripe-js
// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
  typescript: true,
});
// 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;
}

One-Time Payment (Checkout Session)

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(request: NextRequest) {
  const { priceId, quantity = 1 } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity }],
    success_url: `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${request.nextUrl.origin}/checkout/cancel`,
    metadata: { source: "website" },
  });

  return NextResponse.json({ url: session.url });
}

Subscription Checkout

// app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(request: NextRequest) {
  const { priceId, customerId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    customer: customerId || undefined,
    customer_creation: customerId ? undefined : "always",
    line_items: [{ price: priceId, quantity: 1 }],
    subscription_data: {
      trial_period_days: 14,
      metadata: { source: "website" },
    },
    success_url: `${request.nextUrl.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${request.nextUrl.origin}/pricing`,
    allow_promotion_codes: true,
  });

  return NextResponse.json({ url: session.url });
}

Webhook Handler

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed");
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionUpdate(subscription);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCanceled(subscription);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
  }

  return NextResponse.json({ received: true });
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string | null;

  // Update your database with the subscription info
  // await db.update(users).set({ stripeCustomerId: customerId, subscriptionId }).where(...)
  console.log("Checkout complete:", { customerId, subscriptionId });
}

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  const status = subscription.status;
  const customerId = subscription.customer as string;

  // Update subscription status in your database
  console.log("Subscription updated:", { customerId, status });
}

async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;
  // Revoke access
  console.log("Subscription canceled:", customerId);
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  // Send notification email, show banner, etc.
  console.log("Payment failed:", customerId);
}

Customer Portal

// app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(request: NextRequest) {
  const { customerId } = await request.json();

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${request.nextUrl.origin}/dashboard`,
  });

  return NextResponse.json({ url: portalSession.url });
}

Pricing Page Component

"use client";

import { useState } from "react";
import { getStripe } from "@/lib/stripe-client";

interface Plan {
  name: string;
  monthlyPriceId: string;
  yearlyPriceId: string;
  monthlyPrice: number;
  yearlyPrice: number;
  features: string[];
  popular?: boolean;
}

const plans: Plan[] = [
  {
    name: "Starter",
    monthlyPriceId: "price_starter_monthly",
    yearlyPriceId: "price_starter_yearly",
    monthlyPrice: 19,
    yearlyPrice: 190,
    features: ["5 projects", "Basic analytics", "Email support"],
  },
  {
    name: "Pro",
    monthlyPriceId: "price_pro_monthly",
    yearlyPriceId: "price_pro_yearly",
    monthlyPrice: 49,
    yearlyPrice: 490,
    features: ["Unlimited projects", "Advanced analytics", "Priority support", "Custom domains"],
    popular: true,
  },
];

export function PricingCards() {
  const [annual, setAnnual] = useState(false);
  const [loading, setLoading] = useState<string | null>(null);

  async function handleSubscribe(plan: Plan) {
    setLoading(plan.name);
    try {
      const priceId = annual ? plan.yearlyPriceId : plan.monthlyPriceId;
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId }),
      });
      const { url } = await res.json();
      const stripe = await getStripe();
      if (url) window.location.href = url;
    } finally {
      setLoading(null);
    }
  }

  return (
    <div>
      <div className="flex items-center justify-center gap-3 mb-8">
        <span className={annual ? "text-muted-foreground" : "font-medium"}>Monthly</span>
        <button
          onClick={() => setAnnual(!annual)}
          className={`relative w-12 h-6 rounded-full transition-colors ${
            annual ? "bg-primary" : "bg-muted"
          }`}
          role="switch"
          aria-checked={annual}
        >
          <span
            className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
              annual ? "translate-x-6" : ""
            }`}
          />
        </button>
        <span className={annual ? "font-medium" : "text-muted-foreground"}>
          Annual <span className="text-green-600 text-xs">Save 20%</span>
        </span>
      </div>

      <div className="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
        {plans.map((plan) => (
          <div
            key={plan.name}
            className={`border rounded-xl p-6 ${
              plan.popular ? "border-primary ring-1 ring-primary" : ""
            }`}
          >
            {plan.popular && (
              <span className="text-xs font-medium bg-primary text-primary-foreground px-2 py-0.5 rounded-full">
                Most popular
              </span>
            )}
            <h3 className="text-xl font-bold mt-2">{plan.name}</h3>
            <p className="text-3xl font-bold mt-2">
              ${annual ? plan.yearlyPrice : plan.monthlyPrice}
              <span className="text-sm font-normal text-muted-foreground">
                /{annual ? "year" : "month"}
              </span>
            </p>
            <ul className="mt-4 space-y-2">
              {plan.features.map((f) => (
                <li key={f} className="flex items-center gap-2 text-sm">
                  <svg className="w-4 h-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">
                    <path
                      fillRule="evenodd"
                      d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                    />
                  </svg>
                  {f}
                </li>
              ))}
            </ul>
            <button
              onClick={() => handleSubscribe(plan)}
              disabled={loading !== null}
              className="mt-6 w-full py-2 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50"
            >
              {loading === plan.name ? "Redirecting..." : "Get started"}
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Need Payment Integration?

We build complete payment systems with Stripe including subscriptions, invoicing, and billing portals. Contact us to get started.

StripepaymentssubscriptionsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles