DevStacked
LinkedIn Logo'GitHub Logo'Upwork Logo'
May 12, 20269 min read

How to Integrate Stripe Checkout with Next.js 16 (2026 Edition)

Learn how to integrate Stripe Checkout with Next.js 16 App Router using Server Actions, TypeScript, Webhooks, and secure payment flows for SaaS applications.

If you're building a SaaS product with Next.js, one of the fastest and most reliable ways to accept payments is by using Stripe Checkout.

In this guide, you'll learn how to integrate Stripe Checkout with Next.js 16 using:

  • App Router
  • Server Actions
  • TypeScript
  • Stripe Webhooks
  • Hosted Checkout Pages

By the end, you'll have a fully working Stripe payment flow with webhook handling and order verification.


What We’re Building

In this tutorial, we’ll build a simple SaaS pricing flow where users can purchase one of two packages:

  • Harmony
  • Legacy

When a user clicks a checkout button:

  1. A Stripe Checkout Session will be created
  2. The user will be redirected to Stripe’s hosted checkout page
  3. After payment, Stripe redirects the user back to your website
  4. Stripe sends a webhook event to your Next.js application
  5. Your app verifies the payment and creates an order

This guide focuses mainly on Stripe + Next.js integration rather than UI design.


Two Ways to Integrate Stripe Checkout

1. Hosted Stripe Checkout

In this method:

  • Users are redirected to a secure checkout page hosted by Stripe
  • Stripe handles payment security and PCI compliance
  • After payment, users are redirected back to your website

This is the easiest and safest option for most SaaS applications.


2. Embedded Stripe Checkout

In this approach:

  • Stripe Checkout is embedded directly inside your website
  • You can customize the UI more deeply
  • No external redirect is required

This guide focuses on the hosted checkout approach.


Create a Next.js 16 Project

Create a new Next.js application:

npx create-next-app@latest myapp

Choose the options that fit your setup.

Recommended:

  • TypeScript
  • App Router
  • src/ directory
  • React Compiler

Install Stripe

Install the official Stripe SDK:

npm install stripe

Configure Stripe Server SDK

Create the following file:

src/lib/stripe/server.ts

Add this code:

import Stripe from 'stripe';

export const stripe = new Stripe(
  process.env.STRIPE_SECRET_KEY!,
  {
    apiVersion: '2026-04-22.dahlia'
  }
);

This initializes the Stripe SDK using your secret API key.


Get Your Stripe API Keys

Go to:

https://dashboard.stripe.com

Enable Test Mode from the dashboard.

Stripe Test Mode Dashboard

Copy your:

  • sk_test_... secret key
  • pk_test_... publishable key

Configure Environment Variables

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

STRIPE_SECRET_KEY=sk_test_****
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_****
NEXT_PUBLIC_APP_URL=http://localhost:3000

Important Notes

  • STRIPE_SECRET_KEY should only be used on the server
  • NEXT_PUBLIC_ variables are exposed to the client
  • Always switch to live keys before production deployment

Create Products in Stripe

Inside the Stripe dashboard:

  1. Go to Product Catalog
  2. Click Create Product
  3. Enter:
    • Product name
    • Pricing
    • Currency
    • Billing type

If users pay once:

  • Select One-off

If users subscribe monthly/yearly:

  • Select Recurring

Stripe Add Product Form


Copy Stripe Price IDs

After creating products:

  1. Open the product
  2. Click the three-dot menu
  3. Click Copy Price ID

Stripe Product Details Page

Add them to .env.local:

NEXT_PUBLIC_STRIPE_HARMONY_PRICE_ID=price_xxx
NEXT_PUBLIC_STRIPE_LEGACY_PRICE_ID=price_xxx

Create Package Constants

Create:

src/lib/constants/package-tier.ts

Add:

export const PKG_TIER = {
  HARMONY: 'harmony',
  LEGACY: 'legacy'
} as const;

export const PACKAGES = {
  harmony: {
    id: 'harmony',
    name: 'Harmony',
    description: 'The complete memorial song',
    price: 399,
    turnaroundHours: 72,
    maxRevisions: 2,
    stripePriceId: process.env.NEXT_PUBLIC_STRIPE_HARMONY_PRICE_ID!,
    features: [
      'Custom AI-generated memorial song',
      'Two rounds of lyric revisions',
      'Single-photo video with scrolling lyrics',
      'Social media versions'
    ]
  },

  legacy: {
    id: 'legacy',
    name: 'Legacy',
    description: 'Their full story, beautifully told',
    price: 599,
    turnaroundHours: 96,
    maxRevisions: 3,
    stripePriceId: process.env.NEXT_PUBLIC_STRIPE_LEGACY_PRICE_ID!,
    features: [
      'Everything in Harmony',
      'Multi-photo slideshow video',
      'Video revisions',
      'Social media versions'
    ]
  }
};

export type PkgTierType = keyof typeof PACKAGES;

Why Use Constants Instead of Hardcoded Strings?

Avoid writing values like "harmony" everywhere in your app.

Using constants:

  • reduces bugs
  • improves autocomplete
  • makes refactoring easier
  • keeps your app scalable

Create a Server Action

Create:

src/lib/actions/start-checkout.ts

Add:

"use server";

export async function startCheckout() {

}

What Are Server Actions?

Server Actions are asynchronous functions executed on the server in Next.js.

They are commonly used for:

  • form submissions
  • database mutations
  • payment processing
  • secure backend logic

Official docs: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations


Create Package UI

Create:

src/components/package-list.tsx

Add:

'use client';

import { useActionState } from "react";
import { startCheckout } from "@/lib/actions/start-checkout";
import { PACKAGES } from "@/lib/constants/package-tier";

export default function PackageList() {
  const [state, action, isPending] = useActionState(
    startCheckout,
    { error: '' }
  );

  return (
    <div>
      <form action={action}>
        {Object.values(PACKAGES).map(pkg => (
          <button
            key={pkg.id}
            type="submit"
            name="packageTier"
            value={pkg.id}
            disabled={isPending}
            className="border border-gray-300 p-3 disabled:opacity-50"
          >
            {isPending ? 'Redirecting...' : pkg.name}
          </button>
        ))}
      </form>

      {state?.error && (
        <div className="text-red-500">
          {state.error}
        </div>
      )}
    </div>
  );
}

Understanding useActionState

useActionState():

  • connects forms with Server Actions
  • manages loading state
  • handles server responses

It accepts:

  1. your action function
  2. initial state

And returns:

  • current state
  • form action
  • pending state

Render Packages on Home Page

Update your homepage:

import PackageList from "@/components/package-list";

export default function Page() {
  return <PackageList />;
}

Create Stripe Checkout Session

Now update:

src/lib/actions/start-checkout.ts
"use server";

import { redirect } from "next/navigation";
import { PACKAGES, PkgTierType } from "../constants/package-tier";
import { stripe } from "../stripe/server";

export async function startCheckout(
  prev: unknown,
  formData: FormData
) {
  try {
    const pkgTier = formData.get('packageTier') as PkgTierType;

    if (!pkgTier || !PACKAGES[pkgTier]) {
      return { error: 'Invalid package tier' };
    }

    const session = await stripe.checkout.sessions.create({
      mode: 'payment',

      line_items: [
        {
          price: PACKAGES[pkgTier].stripePriceId,
          quantity: 1
        }
      ],

      success_url:
        `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,

      cancel_url:
        `${process.env.NEXT_PUBLIC_APP_URL}/`,

      metadata: {
        packageTier: pkgTier
      },

      customer_creation: 'always',

      billing_address_collection: 'auto',

      phone_number_collection: {
        enabled: true
      }
    });

    if (!session.url) {
      throw new Error('Failed to create checkout session');
    }

    redirect(session.url);

  } catch (err) {
    return {
      error:
        err instanceof Error
          ? err.message
          : 'Failed to checkout'
    };
  }
}

Understanding the Checkout Session

mode

mode: 'payment'

Used for one-time payments.

For subscriptions use:

mode: 'subscription'

line_items

Defines what the user is purchasing.


success_url

Where Stripe redirects users after successful payment.


cancel_url

Where Stripe redirects users after cancellation.


metadata

Useful for attaching:

  • package IDs
  • user IDs
  • internal references

customer_creation

Automatically creates Stripe customers.


Important Security Note

Never trust prices coming from the frontend.

Always:

  • validate product IDs
  • validate Stripe price IDs
  • verify payments server-side

Create Stripe Webhook Route

Create:

src/app/api/webhook/route.ts

Configure Stripe Webhooks

Inside Stripe dashboard:

  1. Go to Developers
  2. Click Webhooks

Stripe Dashboard Developers Menu

  1. Click Add Destination
  2. Enter:
https://yourdomain.com/api/webhook
  1. Select:
  • checkout.session.completed
  1. Copy the signing secret

Add to .env.local:

STRIPE_WEBHOOK_SECRET=whsec_xxx

Test Webhooks Locally

Install the Stripe CLI:

https://docs.stripe.com/stripe-cli

Run:

stripe listen --forward-to localhost:3000/api/webhook

Stripe will generate a webhook signing secret for local development.


Implement the Webhook Handler

Add:

import { PACKAGES } from "@/lib/constants/package-tier";
import { stripe } from "@/lib/stripe/server";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";

const endpointSecret =
  process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  try {
    const payload = await req.text();

    const sig =
      (await headers()).get('stripe-signature')!;

    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        payload,
        sig,
        endpointSecret
      );

    } catch (err) {
      const errorMessage =
        err instanceof Error
          ? err.message
          : 'Unknown error';

      return NextResponse.json(
        { message: `Webhook Error: ${errorMessage}` },
        { status: 400 }
      );
    }

    if (event.type === 'checkout.session.completed') {

      const session =
        event.data.object as Stripe.Checkout.Session;

      if (session.payment_status !== 'paid') {
        return NextResponse.json(
          { message: 'Payment not completed' },
          { status: 200 }
        );
      }

      const pkgTier =
        session.metadata?.packageTier;

      const customerDetails =
        session.customer_details;

      const customerEmail =
        customerDetails?.email;

      if (!pkgTier || !(pkgTier in PACKAGES)) {
        throw new Error('Invalid package tier');
      }

      if (!customerEmail) {
        throw new Error('Customer email required');
      }

      // Create database order here
      // Send confirmation email here
    }

    return NextResponse.json(
      { message: 'Received' },
      { status: 200 }
    );

  } catch (err) {

    const error =
      err instanceof Error
        ? err.message
        : 'Something went wrong';

    console.error(error);

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

Why Use req.text()?

In Next.js App Router route handlers, we can safely use:

await req.text()

Unlike Pages Router, no additional body-parser configuration is required.


Prevent Duplicate Orders

Stripe automatically retries failed webhooks.

Always store:

  • Stripe event IDs
  • checkout session IDs

before processing orders.

This prevents:

  • duplicate emails
  • duplicate orders
  • duplicate fulfillment

Subscription Webhook Events

This guide focuses on one-time payments.

If you're using subscriptions, also listen for:

  • invoice.paid
  • customer.subscription.updated
  • customer.subscription.deleted

Test Your Checkout

Start your Next.js app:

npm run dev

Click one of your package buttons.

You should:

  1. Redirect to Stripe Checkout
  2. Complete payment
  3. Return to your website
  4. Receive webhook events successfully

Final Thoughts

You now have a fully working Stripe Checkout integration using:

  • Next.js 16
  • App Router
  • Server Actions
  • Stripe Webhooks
  • TypeScript

This setup is scalable, production-friendly, and ideal for SaaS applications.

From here you can expand into:

  • subscriptions
  • invoices
  • customer portals
  • usage billing
  • Stripe Connect
  • embedded checkout

Useful Resources

next.jsstripetypeScriptsaaspaymentswebhooksserver actionsapp routerreact