Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 });
}// 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>
);
}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.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.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 },
],stripe.checkout.sessions.create returns Promise<Stripe.Checkout.Session>.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.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: "...",
};session.url expires after 24 hours. Do not store it for later use.amount_total is in the smallest currency unit (cents for USD). Divide by 100 before displaying.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.
| 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 |
redirect() to send the user to StripeStripe 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.
"payment" -- one-time payment"subscription" -- recurring billing"setup" -- save a card without charging itA 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.
line_items: [{
price_data: {
currency: "usd",
product_data: { name: "Custom Widget" },
unit_amount: 2999, // $29.99 in cents
},
quantity: 1,
}]Stripe stores amounts in the smallest currency unit (cents for USD). So 2999 means $29.99. Always divide by 100 for dollar display.
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.
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: "cus_xxx",
line_items: [{ price: "price_xxx", quantity: 1 }],
success_url: "...",
cancel_url: "...",
});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.
import type Stripe from "stripe";
const params: Stripe.Checkout.SessionCreateParams = {
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "...",
cancel_url: "...",
};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.
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥