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.creategenerates 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 yoursuccess_url.- The
modeparameter determines the payment type:"payment"for one-time,"subscription"for recurring, or"setup"for saving a card without charging. line_itemscan reference existing Price objects (created in the Dashboard) or use inlineprice_datafor 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.createreturnsPromise<Stripe.Checkout.Session>.- The
session.urlproperty is typed asstring | null. It isnullonly for embedded checkout mode, so the!assertion is safe for redirect-based flows. - Use
Stripe.Checkout.SessionCreateParamsto 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.urlexpires after 24 hours. Do not store it for later use.- The
amount_totalis 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 aNEXT_REDIRECTerror 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
| Approach | Pros | Cons |
|---|---|---|
| Stripe Checkout (redirect) | Minimal code, PCI-compliant, supports 40+ payment methods | Limited UI customization |
| Embedded Checkout | Stays on your site via iframe | Still limited styling options |
| PaymentIntent + PaymentElement | Full UI control | More code, more responsibility |
| Payment Links | Zero code needed | No 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