Revenue Simulator

← Back to Blog

How to Integrate Stripe & PayPal in a Next.js SaaS: Complete Developer Guide

Published August 8, 2026 · 13 min read

Almost every web-based SaaS ends up needing both Stripe and PayPal — Stripe for its superior card economics and clean developer experience, PayPal for the customer segments that only complete a purchase when they see that familiar blue button. This guide walks through a production-ready integration of both platforms in a Next.js 14 App Router application: client-side checkout UIs, server-side verification, webhooks, environment variables, test mode, and the six pitfalls that take most teams hours to debug. You'll be able to ship a dual-provider checkout that handles subscriptions, one-time purchases, and webhook-driven fulfillment.

Prerequisites

Install the official SDKs for both platforms and confirm your Next.js version is 14+ with the App Router:

npm install @stripe/stripe-js @stripe/stripe-node @paypal/react-paypal-js # Add your keys to .env.local (never commit these) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx STRIPE_SECRET_KEY=sk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx NEXT_PUBLIC_PAYPAL_CLIENT_ID=test-client-id

The rule that keeps you safe: anything starting with NEXT_PUBLIC_ reaches the browser; anything without that prefix stays server-only. Keep your Stripe secret key, webhook secret, and anything that can move money out of client components.

Part 1: Stripe Integration (Payment Intents)

Stripe's recommended flow for tracking a payment through your own backend is Payment Intents. The pattern is: the client requests a payment intent from your server, the server creates it with the amount and currency, the client confirms it with the card details, and your server verifies the outcome via webhook. Never build the amount client-side — that is how overcharge and tampering bugs happen.

1a. Create the Payment Intent (server)

In App Router, define a route handler at app/api/create-payment-intent/route.ts:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { amount, currency = "usd" } = await req.json();

  const paymentIntent = await stripe.paymentIntents.create({
    amount,              // in cents: $20.00 -> 2000
    currency,
    automatic_payment_methods: { enabled: true },
    metadata: { source: "saas-checkout" },
  });

  return Response.json({ clientSecret: paymentIntent.client_secret });
}

The amount must be in the smallest currency unit — cents for USD, not dollars. A $20 subscription is 2000, and getting this wrong produces payments that are a hundred times too large or too small.

1b. Confirm on the client

On the checkout page, load Stripe.js and confirm the payment with the card element:

"use client";
import { loadStripe } from "@stripe/stripe-js";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export function Checkout() {
  async function pay() {
    const res = await fetch("/api/create-payment-intent", {
      method: "POST",
      body: JSON.stringify({ amount: 2000 }),
    });
    const { clientSecret } = await res.json();
    const stripe = await stripePromise;
    await stripe!.confirmPayment({
      elements: stripe!.elements(),
      clientSecret,
      confirmParams: { return_url: window.location.origin + "/success" },
    });
  }
  return <button onClick={pay}>Pay $20.00</button>;
}

The card input itself is typically a Stripe PaymentElement mounted into a container div, which handles PCI compliance by keeping card data inside Stripe's iframe. You never touch raw card numbers.

1c. Verify with a webhook (server)

Webhooks are the source of truth for whether a payment actually succeeded. Never trust the client's return to the success page alone — the user can close the browser mid-confirmation. Handle payment_intent.succeeded in a route handler:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(
    await req.text(), sig, process.env.STRIPE_WEBHOOK_SECRET!
  );
  if (event.type === "payment_intent.succeeded") {
    // grant access / provision the account
  }
  return Response.json({ received: true });
}

Part 2: PayPal Integration (Orders API)

PayPal's modern flow uses the Orders API. The client fires a create-order request to your server, the server returns an order ID, the client renders the Smart Buttons which capture the payment, and your server verifies via webhook. The @paypal/react-paypal-js package provides the button UI as a React component.

2a. Create an order (server)

// app/api/create-paypal-order/route.ts
const PAYPAL_BASE = process.env.NODE_ENV === "production"
  ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";

async function getToken() {
  const auth = Buffer.from(
    process.env.PAYPAL_CLIENT_ID + ":" + process.env.PAYPAL_SECRET
  ).toString("base64");
  const res = await fetch(PAYPAL_BASE + "/v1/oauth2/token", {
    method: "POST",
    headers: { Authorization: "Basic " + auth },
    body: "grant_type=client_credentials",
  });
  return (await res.json()).access_token;
}

export async function POST() {
  const token = await getToken();
  const res = await fetch(PAYPAL_BASE + "/v2/checkout/orders", {
    method: "POST",
    headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
    body: JSON.stringify({
      intent: "CAPTURE",
      purchase_units: [{
        amount: { currency_code: "USD", value: "20.00" },
      }],
    }),
  });
  const order = await res.json();
  return Response.json({ id: order.id });
}

2b. Render the Smart Buttons (client)

"use client";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";

export function PayPalCheckout() {
  return (
    <PayPalScriptProvider options={{
      clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!,
      currency: "USD",
    }}>
      <PayPalButtons
        createOrder={async () => {
          const r = await fetch("/api/create-paypal-order", { method: "POST" });
          const { id } = await r.json();
          return id;
        }}
        onApprove={async (data) => {
          await fetch("/api/capture-paypal-order", {
            method: "POST",
            body: JSON.stringify({ orderId: data.orderID }),
          });
          window.location.href = "/success";
        }}
      />
    </PayPalScriptProvider>
  );
}

The capture step (/api/capture-paypal-order) should be idempotent on your side and only grant access after PayPal confirms the capture succeeded. Like Stripe, treat the webhook as your source of truth for fulfillment.

Part 3: The Six Pitfalls That Break Integrations

These are the issues that consistently trip up developers on their first dual-provider rollout:

PitfallWhy It HappensFix
Trusting the client success pageUser may close the tab mid-paymentVerify via webhook, not page redirect
Hardcoding amount client-sideEasier to write, but tamperableAlways derive amount on the server
Forgetting test vs live modesSandbox keys and live keys look identicalSeparate env vars per environment
No webhook verificationAnyone can POST a fake eventVerify signature on both platforms
Webhook endpoint not securedMissing auth on the routeCheck signature in every POST handler
Not idempotent captureDouble-tap on the pay buttonGuard with order-id lookup before granting

Choosing Which to Default To

As covered in our Stripe vs PayPal cost comparison, the economics usually favor Stripe by roughly 0.6% + $0.19 per transaction. The practical pattern for most SaaS is: show Stripe as the primary option and PayPal as a secondary "or pay with PayPal" button below it. This captures Stripe's better pricing on most transactions while preserving conversion for the significant minority of buyers — heavily concentrated in Germany, the Netherlands, and Latin America — who will only complete a purchase if PayPal is available.

Once your checkout is live, you'll want to know what different providers actually cost you end-to-end. That's exactly what the home-page revenue simulator models: toggle Platform between Stripe and PayPal, set your typical unit price and transaction count, and the commission line updates in real time so you can see the fee delta across your pricing tiers.

Disclaimer: This guide is for informational purposes only and reflects the SDKs and API patterns as of the publish date. Payment platform APIs evolve; always verify against the current official Stripe and PayPal documentation before shipping. Test all flows thoroughly in sandbox mode before going live.