React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripecheckoutsessionsredirectpayments

Stripe Checkout Sessions

Recipe

Create a Checkout Session on the server, then redirect the customer to Stripe's hosted checkout page. Stripe handles the entire payment UI, PCI compliance, and payment method display.

Create a Server Action to generate the session:

// app/actions/checkout.ts
"use server";
 
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
 
export async function createCheckoutSession(priceId: string) {
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });
 
  redirect(session.url!);
}

Or use a Route Handler:

// app/api/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { priceId } = await request.json();
 
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  });
 
  return NextResponse.json({ url: session.url });
}

Working Example

// app/buy/page.tsx
import { createCheckoutSession } from "@/app/actions/checkout";
 
export default function BuyPage() {
  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">Premium Course</h1>
      <p className="text-gray-600 mb-6">
        One-time purchase for lifetime access.
      </p>
 
      <form action={createCheckoutSession.bind(null, "price_xxx123")}>
        <button
          type="submit"
          className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700"
        >
          Buy for $49
        </button>
      </form>
    </div>
  );
}
// app/success/page.tsx
import { stripe } from "@/lib/stripe";
 
interface SuccessPageProps {
  searchParams: Promise<{ session_id?: string }>;
}
 
export default async function SuccessPage({ searchParams }: SuccessPageProps) {
  const { session_id } = await searchParams;
 
  if (!session_id) {
    return <p>Invalid session.</p>;
  }
 
  const session = await stripe.checkout.sessions.retrieve(session_id, {
    expand: ["line_items", "customer"],
  });
 
  return (
    <div className="max-w-md mx-auto p-8 text-center">
      <h1 className="text-2xl font-bold mb-4">Payment Successful!</h1>
      <p className="text-gray-600">
        Thank you for your purchase. Your payment of{" "}
        {(session.amount_total! / 100).toFixed(2)}{" "}
        {session.currency?.toUpperCase()} has been received.
      </p>
      <p className="mt-4 text-sm text-gray-500">
        Session ID: {session.id}
      </p>
    </div>
  );
}

Deep Dive

How It Works

  • stripe.checkout.sessions.create generates a session on Stripe's servers and returns a URL. Redirecting the customer to that URL opens the Stripe-hosted checkout page.
  • {CHECKOUT_SESSION_ID} is a template variable that Stripe replaces with the actual session ID when redirecting back to your success_url.
  • The mode parameter determines the payment type: "payment" for one-time, "subscription" for recurring, or "setup" for saving a card without charging.
  • line_items can reference existing Price objects (created in the Dashboard) or use inline price_data for dynamic pricing.
  • Stripe Checkout supports 40+ payment methods, handles SCA/3D Secure automatically, and provides a mobile-optimized UI out of the box.

Variations

Inline price data (no pre-created Price object):

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [
    {
      price_data: {
        currency: "usd",
        product_data: {
          name: "Custom Widget",
          description: "A one-of-a-kind widget",
        },
        unit_amount: 2999, // $29.99 in cents
      },
      quantity: 1,
    },
  ],
  success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/shop`,
});

Attach to an existing customer:

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  customer: "cus_xxx",
  line_items: [{ price: "price_xxx", quantity: 1 }],
  success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});

Multiple line items:

line_items: [
  { price: "price_basic", quantity: 2 },
  { price: "price_addon", quantity: 1 },
],

TypeScript Notes

  • stripe.checkout.sessions.create returns Promise<Stripe.Checkout.Session>.
  • The session.url property is typed as string | null. It is null only for embedded checkout mode, so the ! assertion is safe for redirect-based flows.
  • Use Stripe.Checkout.SessionCreateParams to type configuration objects if you build them dynamically.
import type Stripe from "stripe";
 
const params: Stripe.Checkout.SessionCreateParams = {
  mode: "payment",
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: "...",
  cancel_url: "...",
};

Gotchas

  • Never trust the success page alone to confirm payment. Always verify via webhooks or by retrieving the session server-side. A user could navigate to your success URL manually.
  • session.url expires after 24 hours. Do not store it for later use.
  • The amount_total is in the smallest currency unit (cents for USD). Divide by 100 before displaying.
  • If you use redirect() from Next.js in a Server Action, it throws a NEXT_REDIRECT error internally. This is expected behavior -- do not wrap it in a try/catch that swallows the error.
  • Stripe Checkout does not allow full UI customization. If you need a fully branded checkout, use PaymentIntent with PaymentElement instead.

Alternatives

ApproachProsCons
Stripe Checkout (redirect)Minimal code, PCI-compliant, supports 40+ payment methodsLimited UI customization
Embedded CheckoutStays on your site via iframeStill limited styling options
PaymentIntent + PaymentElementFull UI controlMore code, more responsibility
Payment LinksZero code neededNo programmatic control

FAQs

What is the difference between using a Server Action and a Route Handler to create a Checkout Session?
  • A Server Action can be called directly from a form action or client code and uses redirect() to send the user to Stripe
  • A Route Handler returns the session URL as JSON, requiring the client to handle the redirect manually
  • Server Actions are simpler for form-based flows; Route Handlers are better for API-style usage
What does the {CHECKOUT_SESSION_ID} template variable do in the success_url?

Stripe replaces {CHECKOUT_SESSION_ID} with the actual session ID when redirecting the customer back to your site. You can then retrieve the session server-side to verify payment details.

What are the three possible values for the mode parameter?
  • "payment" -- one-time payment
  • "subscription" -- recurring billing
  • "setup" -- save a card without charging it
Gotcha: Why should you never trust the success page alone to confirm payment?

A user could manually navigate to your success URL without paying. Always verify payment via webhooks (checkout.session.completed) or by retrieving the session server-side and checking its payment_status.

How do you use inline price_data instead of a pre-created Price ID?
line_items: [{
  price_data: {
    currency: "usd",
    product_data: { name: "Custom Widget" },
    unit_amount: 2999, // $29.99 in cents
  },
  quantity: 1,
}]
Why does amount_total need to be divided by 100 before displaying?

Stripe stores amounts in the smallest currency unit (cents for USD). So 2999 means $29.99. Always divide by 100 for dollar display.

Gotcha: What happens if you wrap redirect() in a try/catch inside a Server Action?

redirect() throws a NEXT_REDIRECT error internally. If your catch block swallows it, the redirect never happens. Either call redirect() outside the try/catch, or re-throw redirect errors explicitly.

How do you attach a Checkout Session to an existing Stripe customer?
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  customer: "cus_xxx",
  line_items: [{ price: "price_xxx", quantity: 1 }],
  success_url: "...",
  cancel_url: "...",
});
How long is session.url valid for?

The Checkout Session URL expires after 24 hours. Always generate a fresh session when the user initiates checkout -- never store the URL for later use.

TypeScript: How do you type a dynamic Checkout Session configuration object?
import type Stripe from "stripe";
 
const params: Stripe.Checkout.SessionCreateParams = {
  mode: "payment",
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: "...",
  cancel_url: "...",
};
TypeScript: Why is session.url typed as string | null?

It is null only when using embedded checkout mode (where no redirect URL is needed). For redirect-based flows, it is always a string, making the ! non-null assertion safe.

When should you use Stripe Checkout vs PaymentIntent with PaymentElement?
  • Use Checkout when you want minimal code, hosted UI, and support for 40+ payment methods out of the box
  • Use PaymentIntent + PaymentElement when you need a fully branded, in-app checkout experience
  • Checkout does not allow full UI customization