Przejdź do treści
Guides

Stripe Payment Integration in Next.js Applications

Published on:
·5 min read·Author: MDS Software Solutions Group

Stripe Payment Integration

poradniki

Stripe Payment Integration in Next.js

Handling online payments is a critical component of every e-commerce store and SaaS application. Stripe has been one of the most popular payment processors in the world for years, offering a robust API, excellent documentation, and broad support for modern frameworks. In this guide, we will show you how to integrate Stripe with a Next.js application step by step, using TypeScript, API Routes, webhooks, subscriptions, and many other features.

What Is Stripe?#

Stripe is an online payment processing platform used by startups and global corporations alike (including Shopify, Amazon, and Google). Stripe offers:

  • Payment processing for credit and debit cards in over 135 currencies
  • Stripe Elements — pre-built, customizable UI components for securely collecting card data
  • Checkout Sessions — hosted payment pages with a complete UI
  • Payment Intents API — a flexible API for building custom payment flows
  • Billing — subscription and invoice management
  • Stripe Dashboard — an admin panel for monitoring transactions, refunds, and analytics
  • Stripe CLI — a tool for testing webhooks locally

Stripe is PCI DSS Level 1 compliant, which means card data never passes through your server when you use Elements or Checkout.

Project Setup#

Let's start by installing the required packages in a Next.js project:

// Install dependencies
// npm install stripe @stripe/stripe-js @stripe/react-stripe-js

// Environment variables (.env.local)
// NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
// STRIPE_SECRET_KEY=sk_test_...
// STRIPE_WEBHOOK_SECRET=whsec_...

Next, let's configure the Stripe instance on the server and client sides:

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true,
});
// lib/stripe-client.ts
import { loadStripe, Stripe } from '@stripe/stripe-js';

let stripePromise: Promise<Stripe | null>;

export const getStripe = () => {
  if (!stripePromise) {
    stripePromise = loadStripe(
      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
    );
  }
  return stripePromise;
};

Stripe Checkout Sessions#

The simplest way to accept payments is Stripe Checkout — a hosted payment page fully managed by Stripe. You don't need to build your own form or worry about PCI compliance.

API Route for Creating Sessions#

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

interface CheckoutRequestBody {
  priceId: string;
  quantity?: number;
  mode?: 'payment' | 'subscription';
}

export async function POST(request: NextRequest) {
  try {
    const body: CheckoutRequestBody = await request.json();
    const { priceId, quantity = 1, mode = 'payment' } = body;

    if (!priceId) {
      return NextResponse.json(
        { error: 'Missing required parameter priceId' },
        { status: 400 }
      );
    }

    const session = await stripe.checkout.sessions.create({
      mode,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/cancel`,
      metadata: {
        source: 'next-app',
      },
    });

    return NextResponse.json({ sessionId: session.id, url: session.url });
  } catch (error) {
    console.error('Error creating Checkout session:', error);
    return NextResponse.json(
      { error: 'Failed to create payment session' },
      { status: 500 }
    );
  }
}

Component for Redirecting to Checkout#

// components/CheckoutButton.tsx
'use client';

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

interface CheckoutButtonProps {
  priceId: string;
  mode?: 'payment' | 'subscription';
  label?: string;
}

export function CheckoutButton({
  priceId,
  mode = 'payment',
  label = 'Buy Now',
}: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId, mode }),
      });

      const { sessionId } = await response.json();
      const stripe = await getStripe();

      if (stripe) {
        const { error } = await stripe.redirectToCheckout({ sessionId });
        if (error) {
          console.error('Redirect error:', error.message);
        }
      }
    } catch (error) {
      console.error('Checkout error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      disabled={loading}
      className="bg-indigo-600 text-white px-6 py-3 rounded-lg
                 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
    >
      {loading ? 'Processing...' : label}
    </button>
  );
}

Payment Intents — Advanced Flows#

If you need full control over the payment form's appearance, use the Payment Intents API combined with Stripe Elements. This allows you to build your own UI while Stripe handles secure card data processing.

Creating a Payment Intent Server-Side#

// app/api/payment-intent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

interface PaymentIntentRequest {
  amount: number;
  currency?: string;
  description?: string;
  customerEmail?: string;
}

export async function POST(request: NextRequest) {
  try {
    const body: PaymentIntentRequest = await request.json();
    const { amount, currency = 'usd', description, customerEmail } = body;

    // Server-side validation
    if (!amount || amount < 50) {
      return NextResponse.json(
        { error: 'Minimum amount is $0.50' },
        { status: 400 }
      );
    }

    if (amount > 99999999) {
      return NextResponse.json(
        { error: 'Amount exceeds the allowed limit' },
        { status: 400 }
      );
    }

    // Optionally: find or create a Stripe customer
    let customerId: string | undefined;
    if (customerEmail) {
      const customers = await stripe.customers.list({
        email: customerEmail,
        limit: 1,
      });

      if (customers.data.length > 0) {
        customerId = customers.data[0].id;
      } else {
        const customer = await stripe.customers.create({
          email: customerEmail,
        });
        customerId = customer.id;
      }
    }

    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      description,
      customer: customerId,
      automatic_payment_methods: {
        enabled: true,
      },
      metadata: {
        source: 'custom-form',
      },
    });

    return NextResponse.json({
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id,
    });
  } catch (error) {
    console.error('Error creating PaymentIntent:', error);

    if (error instanceof Stripe.errors.StripeError) {
      return NextResponse.json(
        { error: error.message },
        { status: error.statusCode || 500 }
      );
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Stripe Elements — Secure Payment Form#

Stripe Elements is a set of pre-built UI components that securely collect card data. Card information never reaches your server — it is sent directly to Stripe.

// components/PaymentForm.tsx
'use client';

import { useState, FormEvent } from 'react';
import {
  Elements,
  PaymentElement,
  useStripe,
  useElements,
} from '@stripe/react-stripe-js';
import { getStripe } from '@/lib/stripe-client';
import type { StripeElementsOptions } from '@stripe/stripe-js';

function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
  const [processing, setProcessing] = useState(false);
  const [succeeded, setSucceeded] = useState(false);

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

    if (!stripe || !elements) return;

    setProcessing(true);
    setError(null);

    const { error: submitError } = await elements.submit();
    if (submitError) {
      setError(submitError.message || 'An error occurred');
      setProcessing(false);
      return;
    }

    const { error: confirmError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/checkout/success`,
      },
    });

    if (confirmError) {
      setError(confirmError.message || 'Payment failed');
      setProcessing(false);
    } else {
      setSucceeded(true);
    }
  };

  if (succeeded) {
    return (
      <div className="text-center p-8">
        <h2 className="text-2xl font-bold text-green-600">
          Payment successful!
        </h2>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-6">
      <PaymentElement />
      {error && (
        <div className="text-red-500 text-sm bg-red-50 p-3 rounded">
          {error}
        </div>
      )}
      <button
        type="submit"
        disabled={!stripe || processing}
        className="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg
                   hover:bg-indigo-700 disabled:opacity-50 transition-colors"
      >
        {processing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

interface PaymentFormProps {
  clientSecret: string;
}

export function PaymentForm({ clientSecret }: PaymentFormProps) {
  const options: StripeElementsOptions = {
    clientSecret,
    appearance: {
      theme: 'stripe',
      variables: {
        colorPrimary: '#4f46e5',
        borderRadius: '8px',
      },
    },
  };

  return (
    <Elements stripe={getStripe()} options={options}>
      <CheckoutForm />
    </Elements>
  );
}

Stripe Webhooks#

Webhooks are a mechanism through which Stripe notifies your application about events (e.g., successful payment, failed subscription, refund). This is the only reliable way to confirm payments — never rely solely on redirecting the client to a success page.

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

export const runtime = 'nodejs';

async function handleCheckoutComplete(
  session: Stripe.Checkout.Session
) {
  console.log('Checkout completed:', session.id);

  // Update the order in your database
  // await db.order.update({
  //   where: { stripeSessionId: session.id },
  //   data: { status: 'paid', paidAt: new Date() },
  // });

  // Send a confirmation email
  // await sendOrderConfirmation(session.customer_email);
}

async function handlePaymentFailed(
  paymentIntent: Stripe.PaymentIntent
) {
  console.error('Payment failed:', paymentIntent.id);
  // Notify the user about the failed payment
}

async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription
) {
  console.log('Subscription updated:', subscription.id);
  // Update subscription status in your database
}

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  console.log('Invoice paid:', invoice.id);
  // Record the paid invoice
}

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

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing stripe-signature header' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    console.error('Webhook verification failed:', message);
    return NextResponse.json(
      { error: `Webhook Error: ${message}` },
      { status: 400 }
    );
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(
          event.data.object as Stripe.Checkout.Session
        );
        break;
      case 'payment_intent.payment_failed':
        await handlePaymentFailed(
          event.data.object as Stripe.PaymentIntent
        );
        break;
      case 'customer.subscription.updated':
        await handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;
      case 'invoice.paid':
        await handleInvoicePaid(event.data.object as Stripe.Invoice);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook processing error:', error);
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }
}

Subscriptions and Recurring Billing#

Stripe Billing enables subscription management. You can create pricing plans (Products and Prices) in the Stripe Dashboard and then offer them to customers.

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

interface SubscriptionRequest {
  email: string;
  priceId: string;
  paymentMethodId: string;
}

export async function POST(request: NextRequest) {
  try {
    const { email, priceId, paymentMethodId }: SubscriptionRequest =
      await request.json();

    // Find or create customer
    const customers = await stripe.customers.list({
      email,
      limit: 1,
    });

    let customer = customers.data[0];
    if (!customer) {
      customer = await stripe.customers.create({
        email,
        payment_method: paymentMethodId,
        invoice_settings: {
          default_payment_method: paymentMethodId,
        },
      });
    } else {
      await stripe.paymentMethods.attach(paymentMethodId, {
        customer: customer.id,
      });
      await stripe.customers.update(customer.id, {
        invoice_settings: {
          default_payment_method: paymentMethodId,
        },
      });
    }

    // Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: priceId }],
      payment_settings: {
        payment_method_types: ['card'],
        save_default_payment_method: 'on_subscription',
      },
      expand: ['latest_invoice.payment_intent'],
    });

    const invoice = subscription.latest_invoice as Stripe.Invoice;
    const paymentIntent =
      invoice.payment_intent as Stripe.PaymentIntent;

    return NextResponse.json({
      subscriptionId: subscription.id,
      clientSecret: paymentIntent.client_secret,
      status: subscription.status,
    });
  } catch (error) {
    console.error('Error creating subscription:', error);
    return NextResponse.json(
      { error: 'Failed to create subscription' },
      { status: 500 }
    );
  }
}

// Subscription management — cancellation
export async function DELETE(request: NextRequest) {
  try {
    const { subscriptionId } = await request.json();

    const subscription = await stripe.subscriptions.update(
      subscriptionId,
      {
        cancel_at_period_end: true,
      }
    );

    return NextResponse.json({
      status: subscription.status,
      cancelAt: subscription.cancel_at,
    });
  } catch (error) {
    console.error('Error canceling subscription:', error);
    return NextResponse.json(
      { error: 'Failed to cancel subscription' },
      { status: 500 }
    );
  }
}

SCA and 3D Secure#

Strong Customer Authentication (SCA) is a regulatory requirement in Europe (PSD2) that enforces additional payment authentication. Stripe automatically handles 3D Secure when required. Simply use the Payment Intents API with the automatic_payment_methods option:

// Stripe automatically triggers 3D Secure when the bank requires it.
// On the client side, you just need to handle the requires_action state:
const { error, paymentIntent } = await stripe.confirmCardPayment(
  clientSecret,
  {
    payment_method: {
      card: elements.getElement('card')!,
      billing_details: {
        name: 'John Doe',
        email: 'john@example.com',
      },
    },
  }
);

if (error) {
  // User did not pass 3D Secure verification
  console.error('3DS error:', error.message);
} else if (paymentIntent?.status === 'succeeded') {
  // Payment completed successfully
  console.log('Payment confirmed');
}

Multi-Currency Support#

Stripe supports over 135 currencies. You can dynamically set the currency based on the user's location:

// lib/currency.ts
interface CurrencyConfig {
  currency: string;
  locale: string;
  symbol: string;
  minAmount: number;
}

const CURRENCY_MAP: Record<string, CurrencyConfig> = {
  PL: { currency: 'pln', locale: 'pl-PL', symbol: 'zl', minAmount: 200 },
  DE: { currency: 'eur', locale: 'de-DE', symbol: 'E', minAmount: 50 },
  US: { currency: 'usd', locale: 'en-US', symbol: '$', minAmount: 50 },
  GB: { currency: 'gbp', locale: 'en-GB', symbol: 'P', minAmount: 30 },
};

export function getCurrencyConfig(
  countryCode: string
): CurrencyConfig {
  return CURRENCY_MAP[countryCode] || CURRENCY_MAP['US'];
}

export function formatAmount(
  amount: number,
  currency: string,
  locale: string
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency.toUpperCase(),
  }).format(amount / 100);
}

Testing with Stripe CLI#

Stripe CLI allows you to simulate webhook events locally without deploying your application:

// 1. Install Stripe CLI
// brew install stripe/stripe-cli/stripe (macOS)
// scoop install stripe (Windows)

// 2. Log in
// stripe login

// 3. Listen to webhooks locally
// stripe listen --forward-to localhost:3000/api/webhooks/stripe

// 4. Send a test event
// stripe trigger payment_intent.succeeded

// 5. Test card numbers:
// 4242 4242 4242 4242 — successful payment
// 4000 0025 0000 3155 — requires 3D Secure
// 4000 0000 0000 9995 — declined payment

Stripe CLI will display a webhook signing secret (starting with whsec_) that you need to set in the STRIPE_WEBHOOK_SECRET variable.

PCI Compliance#

PCI DSS compliance is mandatory for anyone processing card payment data. By using Stripe Elements or Checkout, you achieve the highest level of simplification:

  • SAQ A — Checkout form (hosted by Stripe) — fewest requirements
  • SAQ A-EP — Stripe Elements (embedded in your page) — moderate requirements
  • Card data never touches your server — it goes directly to Stripe
  • Stripe is certified as PCI DSS Level 1 — the highest security level

Ensure HTTPS in your production environment and never store raw card data.

Stripe Dashboard#

The Stripe Dashboard is the command center for managing your payments. It offers:

  • Transaction overview in real time with filters and search
  • Customer management — payment history, subscriptions, payment methods
  • Refunds — full and partial, directly from the panel
  • Reports and analytics — revenue, conversion rates, MRR (Monthly Recurring Revenue)
  • Test environment — separate API keys and data, with no risk
  • API logs — view every request with full payload
  • Radar — fraud detection system with ML-powered rules

Comparison with Other Payment Processors#

| Feature | Stripe | PayPal | Square | Adyen | |---|---|---|---|---| | Developer API | Excellent | Good | Good | Excellent | | Documentation | Best-in-class | Good | Good | Good | | TypeScript support | Full | Partial | Partial | Partial | | Subscriptions | Built-in | Built-in | Built-in | Built-in | | 3D Secure | Automatic | Automatic | Manual | Automatic | | Fees (US) | 2.9% + $0.30 | 2.9% + $0.30 | 2.6% + $0.10 | Custom | | Webhooks | Advanced | Advanced | Basic | Advanced | | No-code tools | Limited | Extensive | Extensive | Limited | | Global reach | 46+ countries | 200+ countries | 8 countries | 30+ countries |

Stripe stands out with its excellent DX (Developer Experience), comprehensive TypeScript SDK, and the richest ecosystem of integrations.

Error Handling — Best Practices#

Robust error handling is crucial in payment systems. Here is a pattern that covers the most common scenarios:

// lib/stripe-errors.ts
import Stripe from 'stripe';

interface StripeErrorResponse {
  message: string;
  code: string;
  statusCode: number;
}

export function handleStripeError(
  error: unknown
): StripeErrorResponse {
  if (error instanceof Stripe.errors.StripeCardError) {
    return {
      message: getCardErrorMessage(error.code),
      code: error.code || 'card_error',
      statusCode: 402,
    };
  }

  if (error instanceof Stripe.errors.StripeRateLimitError) {
    return {
      message: 'Too many requests. Please try again shortly.',
      code: 'rate_limit',
      statusCode: 429,
    };
  }

  if (error instanceof Stripe.errors.StripeInvalidRequestError) {
    return {
      message: 'Invalid payment request.',
      code: 'invalid_request',
      statusCode: 400,
    };
  }

  if (error instanceof Stripe.errors.StripeAuthenticationError) {
    return {
      message: 'Payment configuration error.',
      code: 'auth_error',
      statusCode: 500,
    };
  }

  return {
    message: 'An unexpected error occurred. Please try again.',
    code: 'unknown',
    statusCode: 500,
  };
}

function getCardErrorMessage(code: string | undefined): string {
  const messages: Record<string, string> = {
    card_declined: 'Your card was declined.',
    insufficient_funds: 'Insufficient funds on the card.',
    expired_card: 'Your card has expired.',
    incorrect_cvc: 'Incorrect CVC code.',
    processing_error: 'Processing error. Please try again.',
    incorrect_number: 'Incorrect card number.',
  };

  return messages[code || ''] || 'Payment failed.';
}

Server-Side Validation#

Never trust data coming from the client. Every payment request should undergo server-side validation:

// lib/validation.ts
import { z } from 'zod';

export const paymentSchema = z.object({
  amount: z
    .number()
    .int('Amount must be an integer')
    .min(50, 'Minimum amount is $0.50')
    .max(99999999, 'Amount exceeds limit'),
  currency: z.enum(['pln', 'eur', 'usd', 'gbp']),
  description: z
    .string()
    .max(500, 'Description cannot exceed 500 characters')
    .optional(),
  customerEmail: z.string().email('Invalid email address').optional(),
  metadata: z.record(z.string()).optional(),
});

export type PaymentInput = z.infer<typeof paymentSchema>;

// Usage in API Route:
// const result = paymentSchema.safeParse(body);
// if (!result.success) {
//   return NextResponse.json(
//     { error: result.error.flatten() },
//     { status: 400 }
//   );
// }

Summary#

Integrating Stripe with Next.js provides tremendous capabilities — from simple one-time payments to advanced subscription systems. The key elements of a successful integration include:

  1. Stripe Elements or Checkout for securely collecting card data
  2. Payment Intents API for flexible payment flows
  3. Webhooks as the single source of truth for transaction status
  4. Server-side validation of every request
  5. Error handling with clear user-facing messages
  6. Stripe CLI for testing webhooks locally
  7. SCA/3D Secure handled automatically by Payment Intents

Stripe combined with Next.js and TypeScript creates a robust, type-safe, and secure payment solution ready to scale.


Need professional Stripe payment integration for your Next.js application? The MDS Software Solutions Group team specializes in building secure e-commerce and SaaS systems with full online payment support. Contact us to discuss your project and receive a free estimate.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Stripe Payment Integration in Next.js Applications | MDS Software Solutions Group | MDS Software Solutions Group