SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Feb 22, 2026·Tutorial·SecureStartKit Team

How to Add Stripe Payments to Next.js Using Server Actions (2026 Guide)

A production-ready guide to integrating Stripe one-time payments in Next.js 15 with Server Actions, Zod validation, webhooks, and automated email delivery.

Summarize with AI

On this page

  • Table of Contents
  • The Architecture
  • Step 1: Initialize the Stripe SDK
  • Step 2: Create the Checkout Server Action
  • Step 3: Build the Pricing UI
  • Step 4: Handle Webhooks
  • Step 5: Store Purchases in Your Database
  • Step 6: Send a Delivery Email
  • Testing Locally with the Stripe CLI
  • Common Mistakes
  • The Full Payment Flow

On this page

  • Table of Contents
  • The Architecture
  • Step 1: Initialize the Stripe SDK
  • Step 2: Create the Checkout Server Action
  • Step 3: Build the Pricing UI
  • Step 4: Handle Webhooks
  • Step 5: Store Purchases in Your Database
  • Step 6: Send a Delivery Email
  • Testing Locally with the Stripe CLI
  • Common Mistakes
  • The Full Payment Flow

Most Next.js + Stripe tutorials are outdated. They use API Routes, skip input validation, ignore customer management, and treat webhooks as an afterthought. Then developers ship these patterns to production and wonder why payments break.

Server Actions changed the game. Instead of creating /api/checkout routes, you call a server-side function directly from your component. The code is simpler, type-safe, and secure by default [2]. Your Stripe secret key never leaves the server, validation happens before the API call, and the entire flow is a single function.

This guide walks through a production-ready Stripe integration using Server Actions. Not a hello-world example — the actual patterns you need to charge real customers.

Table of Contents

  • The Architecture
  • Step 1: Initialize the Stripe SDK
  • Step 2: Create the Checkout Server Action
  • Step 3: Build the Pricing UI
  • Step 4: Handle Webhooks
  • Step 5: Store Purchases in Your Database
  • Step 6: Send a Delivery Email
  • Testing Locally with the Stripe CLI
  • Common Mistakes
  • The Full Payment Flow

The Architecture

Here's how the pieces fit together:

User clicks "Buy Now"
  → Client component calls Server Action
    → Validate input with Zod
    → Authenticate user
    → Get or create Stripe customer
    → Create Checkout Session
    → Redirect to Stripe hosted checkout
      → User pays on Stripe
        → Stripe sends webhook to your API route
          → Verify signature
          → Record purchase in database
          → Send delivery email

Server Actions handle the checkout flow. An API Route handles the webhook. Everything else — validation, authentication, database writes, email delivery — happens on the server.

Step 1: Initialize the Stripe SDK

Install the Stripe Node.js SDK:

npm install stripe

Then create a lazy-initialized client. This prevents build failures when environment variables aren't set (like in CI or during next build):

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

let _stripe: Stripe | null = null

export function getStripe(): Stripe {
  if (!_stripe) {
    if (!process.env.STRIPE_SECRET_KEY) {
      throw new Error('STRIPE_SECRET_KEY is not set')
    }
    _stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
      apiVersion: '2025-02-24.acacia',
      typescript: true,
    })
  }
  return _stripe
}

Why lazy initialization instead of a top-level new Stripe()? During next build, your Server Action files get imported to analyze their exports. If you initialize Stripe at the top level, the build fails because STRIPE_SECRET_KEY isn't available in the build environment. The lazy pattern defers initialization to runtime when the key is actually needed.

Your environment variables should look like this:

# .env.local
STRIPE_SECRET_KEY=sk_test_...           # Server-side only
STRIPE_WEBHOOK_SECRET=whsec_...         # For webhook verification
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...  # Safe for the browser

The STRIPE_SECRET_KEY must never be prefixed with NEXT_PUBLIC_. If it is, it ships to the browser and anyone can charge your Stripe account [5].

Step 2: Create the Checkout Server Action

This is the core of the integration. A single Server Action that validates input, authenticates the user, manages Stripe customers, and creates the checkout session:

// actions/billing.ts
'use server'

import type Stripe from 'stripe'
import { getStripe } from '@/lib/stripe/client'
import { createAdminClient, getUser } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const checkoutSchema = z.object({
  priceId: z.string().min(1),
  productName: z.string().optional(),
})

export async function createCheckoutSession(
  data: z.infer<typeof checkoutSchema>
) {
  // 1. Validate input
  const parsed = checkoutSchema.safeParse(data)
  if (!parsed.success) {
    return { error: 'Invalid input' }
  }

  // 2. Authenticate the user
  const user = await getUser()
  if (!user) {
    redirect('/login?next=' + encodeURIComponent('/#pricing'))
  }

  const admin = createAdminClient()

  // 3. Get or create Stripe customer
  const { data: customer } = await admin
    .from('customers')
    .select('stripe_customer_id')
    .eq('id', user.id)
    .single()

  let stripeCustomerId = customer?.stripe_customer_id

  if (!stripeCustomerId) {
    const stripeCustomer = await getStripe().customers.create({
      email: user.email!,
      metadata: { supabase_user_id: user.id },
    })

    await admin.from('customers').insert({
      id: user.id,
      stripe_customer_id: stripeCustomer.id,
    })

    stripeCustomerId = stripeCustomer.id
  }

  // 4. Create checkout session
  const session = await getStripe().checkout.sessions.create({
    customer: stripeCustomerId,
    mode: 'payment',
    payment_method_types: ['card'],
    allow_promotion_codes: true,
    line_items: [{ price: parsed.data.priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/purchase/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/#pricing`,
    metadata: {
      user_id: user.id,
      product_id: parsed.data.productName || 'default',
    },
  })

  if (!session.url) {
    return { error: 'Failed to create checkout session' }
  }

  // 5. Redirect to Stripe Checkout
  redirect(session.url)
}

A few things to unpack here.

Zod validation comes first. Before any Stripe API call, validate the input [6]. The priceId must be a non-empty string. If someone sends garbage, you reject it before it hits Stripe. This prevents both bad data and unnecessary API calls.

Customer management matters. Don't create a new Stripe customer on every checkout. Store the stripe_customer_id in your database and reuse it [4]. This keeps purchase history, payment methods, and receipts tied to a single customer in Stripe's dashboard.

Metadata is your safety net. Pass user_id and product_id in the session metadata. When Stripe sends the webhook later, you'll use this metadata to know which user bought which product. Without it, you'd have to reverse-lookup the customer, which is fragile.

redirect() throws on purpose. In Next.js, redirect() works by throwing a special NEXT_REDIRECT error [2]. This is expected behavior, not a bug. Your calling code needs to handle this (more on that below).

Step 3: Build the Pricing UI

The client component calls the Server Action directly. No fetch calls, no API endpoint, no route handler:

// components/Pricing.tsx
'use client'

import { useState } from 'react'
import { createCheckoutSession } from '@/actions/billing'

export function Pricing({ plans }) {
  const [loadingPlan, setLoadingPlan] = useState<string | null>(null)

  async function handleBuy(plan) {
    setLoadingPlan(plan.name)
    try {
      const result = await createCheckoutSession({
        priceId: plan.priceId,
        productName: plan.name.toLowerCase(),
      })
      if (result?.error) {
        console.error('Checkout error:', result.error)
        // Show error toast to user
      }
    } catch {
      // redirect() throws NEXT_REDIRECT — this is expected
    } finally {
      setLoadingPlan(null)
    }
  }

  return (
    <div>
      {plans.map((plan) => (
        <div key={plan.name}>
          <h3>{plan.name}</h3>
          <p>${plan.price}</p>
          <button
            onClick={() => handleBuy(plan)}
            disabled={loadingPlan !== null}
          >
            {loadingPlan === plan.name ? 'Loading...' : 'Buy Now'}
          </button>
        </div>
      ))}
    </div>
  )
}

The try/catch handles both real errors (returned as { error: string }) and the redirect throw. The loading state prevents double-clicks.

Note that the plans themselves should be config-driven. Define your price IDs, features, and pricing in a central config file — not hardcoded in the component.

Step 4: Handle Webhooks

Server Actions handle outgoing requests to Stripe. But when Stripe needs to talk back to you (payment succeeded, payment failed, dispute created), it sends a webhook to your server. This requires an API Route [3].

// app/api/webhooks/stripe/route.ts
import { getStripe } from '@/lib/stripe/client'
import { createAdminClient } from '@/lib/supabase/server'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'

const relevantEvents = new Set([
  'checkout.session.completed',
  'invoice.payment_failed',
  'charge.dispute.created',
])

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

  if (!sig) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 })
  }

  // Verify the webhook signature
  let event: Stripe.Event
  try {
    event = getStripe().webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  // Ignore events we don't care about
  if (!relevantEvents.has(event.type)) {
    return NextResponse.json({ received: true })
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session
        if (session.mode === 'payment') {
          // Record purchase and send email (see next steps)
        }
        break
      }
      case 'invoice.payment_failed': {
        console.error('Payment failed:', event.data.object)
        break
      }
      case 'charge.dispute.created': {
        console.error('Dispute created:', event.data.object)
        break
      }
    }
    return NextResponse.json({ received: true })
  } catch (error) {
    console.error('Webhook handler error:', error)
    return NextResponse.json({ error: 'Handler failed' }, { status: 500 })
  }
}

Always verify the signature. The constructEvent() method checks that the webhook actually came from Stripe using your STRIPE_WEBHOOK_SECRET [3]. Without this, anyone could POST fake events to your webhook endpoint and create fraudulent purchase records.

Read the body as text, not JSON. Stripe's signature verification needs the raw request body. If you parse it as JSON first, the signature check fails because the string representation changes. Use request.text(), not request.json().

Return 200 quickly. Stripe retries failed webhooks for up to 3 days [3]. If your handler takes too long or returns a 5xx, Stripe will keep retrying. Process the event, return { received: true }, and handle any slow work (like sending emails) asynchronously if needed.

Step 5: Store Purchases in Your Database

Inside the checkout.session.completed handler, record the purchase:

case 'checkout.session.completed': {
  const session = event.data.object as Stripe.Checkout.Session

  if (session.mode === 'payment') {
    const admin = createAdminClient()

    await admin.from('purchases').insert({
      id: (session.payment_intent as string) || session.id,
      user_id: session.metadata?.user_id ?? '',
      product_id: session.metadata?.product_id || 'default',
      amount: session.amount_total || 0,
      status: 'completed',
    })
  }
  break
}

The database schema for one-time payments is straightforward:

CREATE TABLE public.purchases (
  id TEXT PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  product_id TEXT NOT NULL,
  amount INTEGER NOT NULL,
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY;
CREATE INDEX idx_purchases_user_id ON public.purchases(user_id);

Enabling RLS with no policies means the anon key can't query this table at all. All reads and writes go through your server-side code using the service_role key.

The metadata from the checkout session is how you connect the Stripe payment back to your user. This is why passing user_id and product_id in Step 2 matters — without it, you'd have no way to know who paid.

Step 6: Send a Delivery Email

After recording the purchase, send a delivery email. This is where you give the customer what they paid for:

if (session.customer_details?.email) {
  await sendPurchaseDeliveryEmail(
    session.customer_details.email,
    session.customer_details.name || 'there',
    session.metadata?.product_id || 'default'
  )
}

Use a transactional email service like Resend or Postmark. Build your emails with React Email for type-safe, componentized templates that render consistently across email clients.

The key detail: use session.customer_details.email, not the email from your database. Stripe provides the email the customer actually used at checkout, which may differ from their account email.

Testing Locally with the Stripe CLI

You can't receive webhooks on localhost without forwarding. The Stripe CLI handles this [7]:

# Install the Stripe CLI, then:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI gives you a temporary webhook secret (whsec_...). Use it as your STRIPE_WEBHOOK_SECRET during development. When you're ready for production, create a webhook endpoint in the Stripe Dashboard pointing to your deployed URL.

To trigger a test payment:

stripe trigger checkout.session.completed

Or just go through the actual checkout flow using Stripe's test card number: 4242 4242 4242 4242 with any future expiry and any CVC.

Common Mistakes

Using request.json() in the webhook handler. This breaks signature verification. Always use request.text() and let constructEvent() handle parsing.

Not storing the Stripe customer ID. If you create a new Stripe customer on every checkout, you lose purchase history and make refunds harder to track.

Hardcoding price IDs in components. Put them in a config file or environment variable. When you create new prices in Stripe (and you will), you don't want to hunt through component files.

Skipping input validation. Even though Server Actions run on the server, the input comes from the client. A malicious user can call your Server Action with arbitrary data. Always validate with Zod [6].

Not handling the redirect() throw. redirect() in Next.js works by throwing. If you don't catch it, your loading state never resets and the button stays disabled.

Trusting the success URL. The user landing on /purchase/success doesn't mean they paid. They could navigate there directly. Only the webhook confirms a real payment. Never grant access based on the redirect alone.

The Full Payment Flow

Here's the complete flow from click to delivery:

  1. User clicks "Buy Now" on the pricing page
  2. Client component calls createCheckoutSession() Server Action
  3. Server Action validates input with Zod, authenticates the user, gets or creates a Stripe customer, creates a Checkout Session, and redirects to Stripe
  4. User completes payment on Stripe's hosted checkout page
  5. Stripe redirects the user to your success page
  6. Stripe sends a checkout.session.completed webhook to your API route
  7. Your webhook handler verifies the signature, records the purchase in your database, and sends a delivery email
  8. The customer gets an email with access to what they purchased

The success page is a courtesy — it tells the user "check your email." The webhook is the source of truth. If the webhook fails, Stripe retries. If the success page fails to load, the user still gets their purchase via email.

This is the pattern we use in SecureStartKit. Every payment flows through validated Server Actions, verified webhooks, and server-side database writes. No client-side Stripe keys, no unvalidated inputs, no reliance on redirect URLs for fulfillment.

References

  1. Stripe Checkout Quickstart for Next.js— docs.stripe.com
  2. Server Actions and Mutations, Next.js Docs— nextjs.org
  3. Stripe Webhooks Documentation— docs.stripe.com
  4. Create a Checkout Session, Stripe API Reference— docs.stripe.com
  5. Stripe Security Guide— docs.stripe.com
  6. Zod: TypeScript-first Schema Validation— zod.dev
  7. Stripe CLI Documentation— docs.stripe.com

Related Posts

Feb 18, 2025·Tutorial

Getting Started with SecureStartKit

Set up your SecureStartKit SaaS template in under 10 minutes. Clone, configure, and deploy.

Feb 17, 2025·Technical

The Modern SaaS Stack: Next.js 15 + Supabase + Stripe

Why Next.js 15, Supabase, and Stripe make the ideal stack for building SaaS products in 2025.

Feb 16, 2025·Tutorial

How to Ship a SaaS in a Weekend

A step-by-step guide to going from idea to deployed SaaS product in a single weekend using SecureStartKit.