Building Secure Stripe Payments with Next.js

💳

Stripe + Next.js

Stripe Integration Guide

Processing payments securely is essential for many web applications. Whether for a SaaS platform or an online store, Stripe provides the infrastructure to handle money on the internet.

Integrating payments involves more than adding a checkout button. It requires handling security, managing state, and verifying transactions. With Next.js 15 and the App Router, the approach to these integrations uses server-side features for better performance and security.

This guide covers building a reliable Stripe integration. We will look at handling errors, securing webhooks, and building a user interface that works well for your customers.

The Goal: A Custom Payment Flow

We are going to build a fully functional payment checkout flow that includes:

  1. Server-Side Payment Intents: Securely initializing transactions on the server.
  2. Stripe Elements: A customizable, PCI-compliant UI for collecting card details.
  3. Real-time Validation: Feedback for users as they type.
  4. Webhook Handling: Listening for asynchronous events from Stripe to fulfill orders reliably.
  5. TypeScript Integration: Ensuring type safety across your entire payment stack.

Here is a simulation of what we will be building. While this demo uses a simulated backend for security reasons, the UI and interaction model mirror exactly what your users will experience with the production integration.

Payment Demo

TEST MODE
$

Card Details

This is a simulated checkout for demonstration purposes.

Why Stripe?

Before we write a single line of code, it's worth understanding why Stripe is the de facto choice for millions of developers.

1. Developer Experience (DX)

Stripe's API is legendary for its consistency and documentation. Their SDKs are typed, their error messages are descriptive, and their CLI tool allows you to test webhooks locally without tunneling; a massive time-saver.

2. Security & Compliance

Handling credit card data is terrifying. One slip-up can lead to massive fines and loss of trust. Stripe allows you to decouple your infrastructure from sensitive data. With Stripe Elements, the card data never touches your server. It goes directly from the client's browser to Stripe's vault, and you receive a secure token to charge. This dramatically simplifies PCI-DSS compliance.

3. Global Reach

Stripe supports 135+ currencies and dozens of payment methods (Apple Pay, Google Pay, SEPA, Alipay, etc.) out of the box. The integration we build today can be easily extended to support these with just a few lines of configuration change.


Prerequisites

To follow along, you will need:

  • Node.js 18+ installed.
  • A Stripe Account (you can sign up for free at stripe.com).
  • A Next.js project (we'll assume you're using the App Router).

getting Your API Keys

  1. Log in to your Stripe Dashboard.
  2. Toggle the "Test Mode" switch in the top right. Always develop in Test Mode.
  3. Go to Developers > API keys.
  4. Copy your Publishable Key (pk_test_...) and Secret Key (sk_test_...).

Security Warning: Never commit your Secret Key to git. Always use environment variables.

Create a .env.local file in your project root:

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

We'll get the STRIPE_WEBHOOK_SECRET later when we set up the CLI.


Phase 1: Setting Up the Foundation

Let's start by installing the necessary dependencies. We need the server-side SDK for API calls and the React libraries for the frontend.

npm install stripe @stripe/stripe-js @stripe/react-stripe-js
# or
bun add stripe @stripe/stripe-js @stripe/react-stripe-js

Understanding the Architecture

A secure payment flow involves a handshake between the Client (Browser), your Server (Next.js API), and Stripe.

  1. Client: Requests to buy a product (e.g., clicks "Checkout").
  2. Server: Calls Stripe to create a PaymentIntent. This object represents the intent to collect money and tracks the lifecycle of the transaction.
  3. Server: Returns the client_secret from the PaymentIntent to the Client.
  4. Client: Uses the client_secret to render the Payment Element.
  5. Client: Submits the payment details directly to Stripe.
  6. Stripe: Processes the payment and returns the result to the Client.
  7. Stripe: Asynchronously sends a Webhook to your Server to confirm the payment succeeded.

Crucial Concept: You should never trust the Client to tell you a payment succeeded. A savvy user can manipulate JavaScript to show a success message without paying. Always rely on the Webhook or a server-side verification to fulfill the order (e.g., send the ebook, unlock the feature).


Phase 2: The Backend (Creating the Payment Intent)

In Next.js App Router, we handle API requests using Route Handlers. Let's create an endpoint that initializes the payment.

Create app/api/create-payment-intent/route.ts:

import { NextResponse } from 'next/server';
import Stripe from 'stripe';

// Initialize Stripe with your secret key
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16', // Use the latest API version
  typescript: true,
});

export async function POST(request: Request) {
  try {
    const { amount } = await request.json();

    // Validations
    if (!amount || amount < 1) {
      return NextResponse.json(
        { error: 'Invalid amount' }, 
        { status: 400 }
      );
    }

    // Create a PaymentIntent with the specified amount.
    // Amount is in the smallest currency unit (e.g., 100 cents = $1.00)
    const paymentIntent = await stripe.paymentIntents.create({
      amount: amount * 100, // Converting dollars to cents
      currency: 'usd',
      // In the latest version of the API, specifying the `automatic_payment_methods` parameter is optional because Stripe enables its functionality by default.
      automatic_payment_methods: {
        enabled: true,
      },
      metadata: {
        integration_check: 'accept_a_payment',
      },
    });

    return NextResponse.json({
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error: any) {
    console.error('Internal Error:', error);
    return NextResponse.json(
      { error: `Internal Server Error: ${error.message}` },
      { status: 500 }
    );
  }
}

Key Details:

  • Currency Units: Stripe almost always works in the smallest currency unit. For USD, that's cents. If you charge $10, you must send 1000. Common mistake!
  • Automatic Payment Methods: By enabling this, Stripe can dynamically show relevant payment methods (like Apple Pay, Google Pay, or Afterpay) based on the user's location and browser capabilities, without you changing your code.

Phase 3: The Frontend (Building the Checkout Form)

Now for the UI. We need to wrap our application (or just the checkout part) in the Elements provider. This provider manages the state of the Stripe components and ensures secure communication.

1. The Stripe Loader

First, create a utility to load the Stripe script effectively. Create lib/stripe.ts:

import { loadStripe } from '@stripe/stripe-js';

// Make sure to call `loadStripe` outside of a component’s render to avoid
// recreating the `Stripe` object on every render.
export const getStripe = () => {
  if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
    throw new Error('Stripe publishable key is missing');
  }
  return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
};

2. The Checkout Form Component

This is where the magic happens. We'll use the PaymentElement component, which is a pre-built UI that automatically validates card numbers, expiration dates, and CVCs.

Create components/CheckoutForm.tsx:

'use client';

import { useState } from 'react';
import {
  PaymentElement,
  useStripe,
  useElements
} from '@stripe/react-stripe-js';

export default function CheckoutForm({ amount }: { amount: number }) {
  const stripe = useStripe();
  const elements = useElements();

  const [message, setMessage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) {
      // Stripe.js has not yet loaded.
      return;
    }

    setIsLoading(true);

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        // Make sure to change this to your payment completion page
        return_url: `${window.location.origin}/completion`,
      },
    });

    // This point will only be reached if there is an immediate error when
    // confirming the payment. Otherwise, your customer will be redirected to
    // your `return_url`.
    if (error.type === "card_error" || error.type === "validation_error") {
      setMessage(error.message ?? "An unexpected error occurred.");
    } else {
      setMessage("An unexpected error occurred.");
    }

    setIsLoading(false);
  };

  return (
    <form id="payment-form" onSubmit={handleSubmit} className="w-full max-w-md mx-auto p-6 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-800">
      <h2 className="text-2xl font-bold mb-6 text-slate-800 dark:text-white">Complete your payment</h2>
      
      <div className="mb-6">
        <PaymentElement 
          id="payment-element" 
          options={{
            layout: "tabs",
            paymentMethodOrder: ['apple_pay', 'google_pay', 'card']
          }} 
        />
      </div>
      
      <button 
        disabled={isLoading || !stripe || !elements} 
        id="submit"
        className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center"
      >
        <span id="button-text">
          {isLoading ? (
            <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
          ) : (
            `Pay $${amount}`
          )}
        </span>
      </button>
      
      {/* Show any error or success messages */}
      {message && (
        <div id="payment-message" className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-900/50">
          {message}
        </div>
      )}
    </form>
  );
}

3. The Page Wrapper

Now we need a page to hold the state of the PaymentIntent and wrap the form in the Elements provider.

Create app/checkout/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { getStripe } from '@/lib/stripe';
import CheckoutForm from '@/components/CheckoutForm';
import { Appearance } from '@stripe/stripe-js';

const stripePromise = getStripe();

export default function CheckoutPage() {
  const [clientSecret, setClientSecret] = useState('');

  useEffect(() => {
    // Create PaymentIntent as soon as the page loads
    fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount: 20 }), // e.g. $20.00
    })
      .then((res) => res.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, []);

  // Custom styling for Stripe Elements to match your theme
  const appearance: Appearance = {
    theme: 'night', // or 'stripe', 'night', 'flat'
    variables: {
      colorPrimary: '#4f46e5',
      colorBackground: '#0f172a',
      colorText: '#ffffff',
      colorDanger: '#ef4444',
      fontFamily: 'system-ui, sans-serif',
      spacingUnit: '4px',
      borderRadius: '8px',
    },
  };

  const options = {
    clientSecret,
    appearance,
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
      {clientSecret && (
        <Elements options={options} stripe={stripePromise}>
          <CheckoutForm amount={20} />
        </Elements>
      )}
    </div>
  );
}

Phase 4: Webhooks (The Critical Step)

This is where many developers trip up. Relying on the client-side redirect (return_url) to fulfill orders is insecure. If the user's browser crashes after payment but before the redirect, your database won't know they paid.

Webhooks solve this. Stripe sends a POST request to your server for every event (payment succeeded, payment failed, dispute created, etc.).

1. Setting up the Webhook Route

Create app/api/webhooks/route.ts:

import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true,
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();
  const signature = headers().get('stripe-signature') as string;

  let event: Stripe.Event;

  try {
    // Verify the event came from Stripe
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      webhookSecret
    );
  } catch (err: any) {
    console.error(`Webhook Error: ${err.message}`);
    return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      console.log(`PaymentIntent for ${paymentIntent.amount} was successful!`);
      
      // TODO: Update your database here
      // await updateOrderStatus(paymentIntent.metadata.orderId, 'paid');
      break;
      
    case 'payment_method.attached':
      const paymentMethod = event.data.object as Stripe.PaymentMethod;
      console.log('Payment Method attached:', paymentMethod.id);
      break;
      
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

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

2. Testing Webhooks Locally

Since your localhost isn't public, Stripe can't send requests to it directly. We use the Stripe CLI to forward events.

  1. Install Stripe CLI:
    brew install stripe/stripe-cli/stripe
    
  2. Login:
    stripe login
    
  3. Forward events:
    stripe listen --forward-to localhost:3000/api/webhooks
    

The CLI will output a webhook secret (whsec_...). Copy this into your .env.local as STRIPE_WEBHOOK_SECRET.

Now, when you complete a payment in your local UI, you should see the event appear in your terminal!


Security Best Practices

Security isn't an afterthought; it's the foundation of payments.

  1. Never Validate on Client: Never trust paymentIntent.status on the client to send goods. Always wait for the payment_intent.succeeded webhook.
  2. Idempotency: Webhooks can be sent multiple times for the same event. Ensure your webhook handler is idempotent (handling the same event twice doesn't charge the user twice or ship two items).
    // Example idempotency check
    const order = await db.orders.find(orderId);
    if (order.status === 'paid') return; // Already processed
    
  3. HTTPS: Stripe requires HTTPS for all API calls. Next.js handles this in production, but ensure your deployment (Vercel, AWS, etc.) enforces SSL.
  4. Logging: Log webhook failures aggressively. If your server throws a 500 error during a webhook, Stripe will retry for up to 3 days, but you want to know immediately.

Styling and Customization

Stripe Elements isn't just an iframe; it's a highly customizable UI system. You can match your brand perfectly using the appearance API.

The Appearance Object

The appearance object passed to Elements controls the global styles.

const appearance = {
  theme: 'night', // 'stripe', 'night', 'flat'
  variables: {
    fontFamily: 'Sohne, system-ui, sans-serif',
    fontWeightNormal: '500',
    borderRadius: '8px',
    colorBackground: '#0A2540',
    colorPrimary: '#EFC078',
    accessibleColorOnColorPrimary: '#1A1B25',
    colorText: 'white',
    colorTextSecondary: '#727F96',
    colorTextPlaceholder: '#727F96',
    tabIconColor: 'white',
    logoColor: 'dark'
  },
  rules: {
    '.Input': {
      backgroundColor: '#1A1B25',
      border: '1px solid #2D3748'
    }
  }
};

This flexibility allows you to create seamless experiences where the payment form feels like a native part of your application, not a third-party widget.


Conclusion

Integrating payments is a rite of passage for full-stack developers. While it involves moving parts: intents, elements, webhooks; the result is powerful. You have successfully built a system that:

  • Initiates secure transactions on the server.
  • Collects sensitive data securely on the client.
  • Verifies success asynchronously via webhooks.
  • Looks beautiful and consistent with your brand.

The world of e-commerce is now open to you. Go forth and monetize!