Stripe
Stripe Checkout with Next.js
Create a Stripe Checkout Session from a Next.js route handler, for subscriptions and for one-time payments.
Stripe Checkout is a hosted page. Your Next.js app creates a Session and redirects to session.url. You never collect the card number. The Stripe Subscription Starter uses this for a recurring Pro price. The Job Board Starter and the Marketplace Starter use the same API with mode: "payment".
Subscription session
This is the call in src/app/api/stripe/checkout/route.ts of the subscription starter. The Stripe client is constructed inside the request so a missing STRIPE_SECRET_KEY throws a clear error instead of failing at import time during next build.
import Stripe from "stripe";
export function stripe() {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
return new Stripe(key);
}
const session = await stripe().checkout.sessions.create({
mode: "subscription",
customer,
line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID || "", quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id }
});
customer is a Stripe customer id (cus_...), not your database id. Create it first, save it on the user, and pass it on the next visit so Checkout does not mint a second customer for the same email.
One-time session
A job posting or a marketplace listing is not a subscription. The marketplace kit sends mode: "payment" and an inline price in cents:
await stripe().checkout.sessions.create({
mode: "payment",
line_items: [{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: listing.priceCents,
product_data: { name: listing.title }
}
}],
success_url: `${process.env.APP_URL}/dashboard?paid=1`,
cancel_url: `${process.env.APP_URL}/listings/${listing.slug}`,
metadata: { listingId: listing.id }
});
unit_amount is an integer in the currency’s minor unit. A listing stored as priceCents: 2400 is 24.00 EUR. Do not pass a float.
The job board uses a Dashboard price id (STRIPE_JOB_PRICE_ID) instead of price_data, and puts {CHECKOUT_SESSION_ID} in the success URL so the return route can retrieve the session. That pattern is in accepting paid job submissions.
Redirect status
NextResponse.redirect(session.url) is correct for a GET handler. The marketplace route is a POST and redirects with status 303 so the browser follows with GET. If session.url is missing, send the user back to pricing rather than redirecting to "null".
Checkout collects the payment. It does not update your database by itself. Pair every session with either a webhook or a success handler that reads payment_status from Stripe. See Stripe webhooks with Next.js.
Want to skip the setup? The Stripe Subscription Starter already includes a Checkout route for a subscription price, plus the webhook that records it.