Next.js + Vercel Stack6 min read

Next.js Stripe Integration: Complete Payment Setup Tutorial

Complete step-by-step guide for Next.js Stripe integration using App Router, Server Actions, and webhooks. Build secure payment processing for SaaS MVPs and e-commerce projects.

By John Hashem

Next.js Stripe Integration: Complete Payment Setup Tutorial

Building payment functionality into your Next.js application doesn't have to be overwhelming. This comprehensive tutorial walks you through implementing Stripe payments using Next.js App Router, Server Actions, and webhooks. You'll learn to handle both one-time payments and subscription billing, complete with proper error handling and security best practices.

By the end of this guide, you'll have a production-ready payment system that can process transactions securely, handle webhook events, and provide a smooth checkout experience for your users. This setup is essential for SaaS MVPs, e-commerce projects, and any application requiring payment processing.

Prerequisites

Before starting this tutorial, ensure you have:

  • A Next.js 14+ project with App Router enabled
  • Node.js 18+ installed
  • A Stripe account (free tier works fine)
  • Basic familiarity with React Server Components
  • TypeScript knowledge (optional but recommended)

Step 1: Install and Configure Stripe Dependencies

Start by installing the necessary Stripe packages and setting up your environment variables.

npm install stripe @stripe/stripe-js
npm install -D @types/stripe

Create a .env.local file in your project root and add your Stripe keys:

STRIPE_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here

Find these keys in your Stripe dashboard under Developers > API keys. The webhook secret comes later when you set up webhook endpoints. For production deployments, you'll want to use Stripe's live keys instead of test keys.

Step 2: Create Stripe Client Configuration

Set up your Stripe client instances for both server-side and client-side operations.

Create lib/stripe.ts:

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

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

// Client-side Stripe instance
let stripePromise: Promise<Stripe | null>
export const getStripe = () => {
  if (!stripePromise) {
    stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
  }
  return stripePromise
}

This configuration ensures you have properly typed Stripe instances for both server and client operations. The server instance handles payment processing and webhook verification, while the client instance manages the checkout interface.

Step 3: Build Payment Intent Server Action

Create a Server Action to handle payment intent creation securely on the server side.

Create app/actions/payment.ts:

'use server'

import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'

export async function createPaymentIntent(amount: number, currency: string = 'usd') {
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: amount * 100, // Stripe expects cents
      currency,
      automatic_payment_methods: {
        enabled: true,
      },
    })

    return {
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id,
    }
  } catch (error) {
    console.error('Payment intent creation failed:', error)
    throw new Error('Failed to create payment intent')
  }
}

export async function createCheckoutSession(priceId: string, successUrl: string, cancelUrl: string) {
  try {
    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: successUrl,
      cancel_url: cancelUrl,
    })

    if (session.url) {
      redirect(session.url)
    }
  } catch (error) {
    console.error('Checkout session creation failed:', error)
    throw new Error('Failed to create checkout session')
  }
}

These Server Actions handle the sensitive payment processing logic on the server, keeping your secret keys secure. The payment intent approach gives you more control over the payment flow, while checkout sessions provide a hosted solution that's faster to implement.

Step 4: Create Payment Form Component

Build a React component that handles the payment form interface using Stripe Elements.

Create components/PaymentForm.tsx:

'use client'

import { useState, useEffect } from 'react'
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'
import { getStripe } from '@/lib/stripe'
import { createPaymentIntent } from '@/app/actions/payment'

function CheckoutForm({ amount }: { amount: number }) {
  const stripe = useStripe()
  const elements = useElements()
  const [isProcessing, setIsProcessing] = useState(false)
  const [clientSecret, setClientSecret] = useState('')

  useEffect(() => {
    createPaymentIntent(amount).then(({ clientSecret }) => {
      setClientSecret(clientSecret || '')
    })
  }, [amount])

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

    if (!stripe || !elements || !clientSecret) {
      return
    }

    setIsProcessing(true)

    const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
      payment_method: {
        card: elements.getElement(CardElement)!,
      },
    })

    if (error) {
      console.error('Payment failed:', error)
    } else if (paymentIntent.status === 'succeeded') {
      console.log('Payment succeeded!')
      // Handle successful payment
    }

    setIsProcessing(false)
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      <div className="mb-4">
        <label className="block text-sm font-medium mb-2">Card Details</label>
        <div className="p-3 border rounded-md">
          <CardElement
            options={{
              style: {
                base: {
                  fontSize: '16px',
                  color: '#424770',
                  '::placeholder': {
                    color: '#aab7c4',
                  },
                },
              },
            }}
          />
        </div>
      </div>
      
      <button
        type="submit"
        disabled={!stripe || isProcessing}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isProcessing ? 'Processing...' : `Pay $${amount}`}
      </button>
    </form>
  )
}

export default function PaymentForm({ amount }: { amount: number }) {
  const stripePromise = getStripe()

  return (
    <Elements stripe={stripePromise}>
      <CheckoutForm amount={amount} />
    </Elements>
  )
}

This component provides a complete payment form with card input, loading states, and error handling. The Elements provider wraps the form to enable Stripe's secure card collection functionality.

Step 5: Set Up Webhook Handling

Webhooks are crucial for handling payment events reliably. Create an API route to process Stripe webhooks.

Create app/api/webhooks/stripe/route.ts:

import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import Stripe from 'stripe'

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

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature!,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (error) {
    console.error('Webhook signature verification failed:', error)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object as Stripe.PaymentIntent
        console.log('Payment succeeded:', paymentIntent.id)
        // Update your database, send confirmation email, etc.
        break

      case 'payment_intent.payment_failed':
        const failedPayment = event.data.object as Stripe.PaymentIntent
        console.log('Payment failed:', failedPayment.id)
        // Handle failed payment
        break

      case 'customer.subscription.created':
        const subscription = event.data.object as Stripe.Subscription
        console.log('Subscription created:', subscription.id)
        // Handle new subscription
        break

      case 'invoice.payment_succeeded':
        const invoice = event.data.object as Stripe.Invoice
        console.log('Invoice paid:', invoice.id)
        // Handle successful recurring payment
        break

      default:
        console.log(`Unhandled event type: ${event.type}`)
    }

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

Webhooks ensure your application stays synchronized with Stripe's payment status updates. This is essential for reliable payment processing, especially for subscriptions and recurring billing.

Step 6: Create a Payment Page

Put everything together in a payment page that demonstrates the complete flow.

Create app/checkout/page.tsx:

import PaymentForm from '@/components/PaymentForm'
import { createCheckoutSession } from '@/app/actions/payment'

export default function CheckoutPage() {
  return (
    <div className="min-h-screen bg-gray-50 py-12">
      <div className="max-w-2xl mx-auto">
        <h1 className="text-3xl font-bold text-center mb-8">Complete Your Purchase</h1>
        
        <div className="bg-white rounded-lg shadow-md p-6 mb-8">
          <h2 className="text-xl font-semibold mb-4">Order Summary</h2>
          <div className="flex justify-between items-center">
            <span>Premium Plan</span>
            <span className="font-bold">$29.00</span>
          </div>
        </div>

        <PaymentForm amount={29} />
        
        <div className="mt-8 text-center">
          <form action={async () => {
            'use server'
            await createCheckoutSession(
              'price_your_stripe_price_id',
              `${process.env.NEXT_PUBLIC_BASE_URL}/success`,
              `${process.env.NEXT_PUBLIC_BASE_URL}/checkout`
            )
          }}>
            <button 
              type="submit"
              className="bg-green-600 text-white py-2 px-6 rounded-md hover:bg-green-700"
            >
              Or use Stripe Checkout
            </button>
          </form>
        </div>
      </div>
    </div>
  )
}

This page demonstrates both payment approaches: the custom form using Payment Intents and the hosted Stripe Checkout solution. Choose the approach that best fits your needs.

Common Mistakes and Troubleshooting

Several issues commonly trip up developers when implementing Stripe payments in Next.js.

Webhook endpoint not receiving events: Ensure your webhook URL is publicly accessible and returns a 200 status code. Use tools like ngrok for local development testing. Verify that your webhook secret matches the one in your Stripe dashboard.

Payment amounts incorrect: Remember that Stripe processes amounts in the smallest currency unit (cents for USD). Always multiply dollar amounts by 100 when creating payment intents. Double-check your currency codes match Stripe's supported currencies.

Client-server hydration errors: Keep Stripe Elements components client-side only using the 'use client' directive. Server Actions should handle all sensitive operations like payment intent creation. Never expose secret keys in client-side code.

Next Steps

After completing this basic integration, consider implementing these advanced features for a production-ready system. Add customer management by creating Stripe customer records and associating them with user accounts in your database. Implement subscription management with plan changes, cancellations, and proration handling.

For MVP development, this payment setup integrates perfectly with choosing your MVP tech stack decisions. Consider how payment processing fits into your overall architecture, especially regarding technical debt in MVP development trade-offs.

Set up proper error logging and monitoring for payment failures, implement receipt generation and email notifications, and add comprehensive testing for both successful and failed payment scenarios. These additions will ensure your payment system handles real-world usage reliably.

Ready to build something great?

Let's talk about your project. I offer 1-week MVP sprints, fractional CTO services, and Claude Code consulting.

View All Services