Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

For a Next.js 15 SaaS app, a practical starting pattern is to create Stripe Checkout Sessions in an App Router Route Handler, redirect customers to Stripe-hosted Checkout, and use verified webhooks—not the browser’s return page—to update billing state. Keep price selection and secret-key use on the server, and treat event retries, ordering, and database updates as deliberate parts of the implementation.

How should Stripe fit into a Next.js 15 app?

Keep the payment flow in the server layer. In the App Router, a route.ts file inside app defines a Route Handler using the Web Request and Response APIs. A checkout endpoint can create a Stripe Session; a separate webhook endpoint can receive Stripe’s event notifications.

A typical route layout is:

app/
  api/
    checkout/
      route.ts
    stripe/
      webhook/
        route.ts

These are server endpoints, not client components. Do not expose a Stripe secret key to browser code. Next.js 15 Route Handlers support standard HTTP methods and are not cached by default; payment mutations should be handled as POST requests.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should you use Checkout or Elements?

For a first subscription flow, Stripe-hosted Checkout is a reasonable default because Stripe provides the hosted payment interface. Stripe also offers embedded Elements for products that need more control over the on-site payment experience. Neither option is universally better; choose based on how much of the checkout interface your product needs to own.

Approach UI control Implementation shape Consider it when
Stripe-hosted Checkout Less control over the payment page Create a Checkout Session on the server, then redirect to its URL You want to begin with a Stripe-hosted flow rather than build an embedded payment interface
Embedded Elements More control over the on-site experience Build the payment experience into your app using Stripe’s embedded components Your product requires a more customized payment interface

Checkout Sessions support one-time payments and recurring subscriptions. For subscriptions, set the Session mode to subscription; for a one-time payment, use payment. The example below is for subscriptions.

How do you create a subscription Checkout Session?

Make the browser’s request identify an allowed plan, not dictate a price or amount. Your server should map that plan to a Stripe Price ID from trusted configuration, check that the signed-in user may start checkout, and create the Session. This prevents a client from substituting an arbitrary amount or price ID.

For example, define the Stripe values in server-side environment configuration:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_BASIC=price_...
STRIPE_PRICE_PRO=price_...
APP_URL=http://localhost:3000

The ellipses above indicate values you obtain from your Stripe account; they are not literal values to deploy. Keep secret values out of source control and out of variables exposed to client bundles.

A Route Handler can follow this pattern. Connect requireUser() to your application’s authentication system, and replace the illustrative environment values with the real server configuration for each environment.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const priceByPlan = {
  basic: process.env.STRIPE_PRICE_BASIC!,
  pro: process.env.STRIPE_PRICE_PRO!,
} as const;

type Plan = keyof typeof priceByPlan;

export async function POST(request: Request) {
  const user = await requireUser();
  if (!user) {
    return Response.json({ error: "Sign in required" }, { status: 401 });
  }

  let body: { plan?: string };
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Invalid request body" }, { status: 400 });
  }

  if (!body.plan || !Object.hasOwn(priceByPlan, body.plan)) {
    return Response.json({ error: "Unknown plan" }, { status: 400 });
  }

  const plan = body.plan as Plan;
  const appUrl = process.env.APP_URL;
  if (!appUrl) {
    return Response.json({ error: "App URL is not configured" }, { status: 500 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceByPlan[plan], quantity: 1 }],
    client_reference_id: user.id,
    success_url: `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${appUrl}/billing`,
  });

  if (!session.url) {
    return Response.json({ error: "Checkout URL unavailable" }, { status: 500 });
  }

  return Response.json({ url: session.url });
}

The browser can POST the selected plan to /api/checkout and navigate to the returned URL. The server-side authentication and plan mapping are application-specific: implement them rather than treating this illustrative handler as a complete billing system. In particular, associate the Stripe customer and subscription with the correct application user in your own data model.

The success URL includes Stripe’s documented {CHECKOUT_SESSION_ID} substitution. A success page can show confirmation or retrieve session details for display, but the browser landing there is not proof that your application should grant durable access.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

How should the App Router handle Stripe webhooks?

Use a webhook Route Handler to verify Stripe’s signature against the exact, unparsed request body. Read the body as text before attempting JSON parsing; parsing and re-serializing it can change the bytes used for signature verification. Use the signing secret for this specific webhook endpoint, which is different from the Stripe API secret key.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  if (!signature || !webhookSecret) {
    return new Response("Webhook configuration missing", { status: 400 });
  }

  const rawBody = await request.text();
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch {
    return new Response("Invalid webhook signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object;
      // Reconcile the application user, Stripe customer, and subscription.
      break;
    }
    case "customer.subscription.updated": {
      const subscription = event.data.object;
      // Reconcile the stored subscription state and resulting access.
      break;
    }
    default:
      break;
  }

  return Response.json({ received: true });
}

The comments mark application work, not optional production behavior: connect these events to persistent state updates. Acknowledge only after your handler has safely recorded or completed the work needed to process the event. If processing fails, return an error so the event is not silently treated as handled.

checkout.session.completed and customer.subscription.updated are example event types for a subscription integration, not a complete event policy for every product or payment method. Stripe also documents delayed-payment success and failure events. Decide which events matter for the payment methods and subscription lifecycle your app actually supports.

How should billing state and access be synchronized?

Treat Stripe as the source of payment and subscription events, and your database as the application’s durable record of which user is linked to which Stripe customer and subscription. The browser redirect is useful for the customer experience; it is not the authoritative entitlement update.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Persist an application user identifier and the relevant Stripe customer and subscription identifiers so incoming events can be matched to the right account.
  • Define which verified events change access, and what each change means for your product’s entitlement rules.
  • Make event handling safe to retry. Persist event identifiers or use another idempotency design so receiving the same notification again does not duplicate a state change.
  • Plan for events to arrive more than once or in an order your application did not expect. Reconcile current Stripe state where appropriate instead of assuming each notification is the only update.
  • Keep database writes and entitlement changes consistent if processing fails partway through.

There is no single database schema or race-safe state machine established for every SaaS app. Choose the transaction boundaries, event deduplication strategy, and state transitions to fit your database and access model. Do not grant or revoke access merely because a customer reached a success or cancellation URL.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

How do you test locally and configure production?

Keep test credentials and production credentials separate, and configure the deployed webhook endpoint independently from local event forwarding. The official Next.js Stripe example demonstrates forwarding local events with the Stripe CLI and configuring a live webhook endpoint after deployment.

  1. Configure local test values. Set a test-mode API secret, the test Price IDs your handler maps to plans, and a local APP_URL in your development environment.
  2. Start the app. Run the Next.js development server on the local port you plan to forward events to.
  3. Forward webhook events. In the Stripe CLI, use stripe listen --forward-to localhost:3000/api/stripe/webhook. Use the signing secret reported by the CLI as the local STRIPE_WEBHOOK_SECRET; it is not interchangeable with the live endpoint’s secret.
  4. Exercise the flow. Start checkout through your app, confirm the redirect, and check that the verified event reaches the Route Handler and that your database reconciliation behaves as intended.
  5. Configure the deployed endpoint. After deployment, register the public webhook URL in Stripe and subscribe it to the event types your application handles. Add that endpoint’s signing secret to the deployed server environment.
  6. Verify production configuration. Confirm that the deployed app uses live credentials and matching live Price IDs, that the public webhook route is reachable, and that production secrets are not exposed to client code.

The documented deployment example uses Vercel, but the essential requirements also apply to other Next.js hosts: support the needed Route Handler runtime, make the webhook endpoint reachable by Stripe, and provide the correct environment variables to server code. The live endpoint needs its own configured signing secret; a local CLI secret does not configure production.

Should you integrate into an existing app or start from a SaaS template?

Choose based on how much of the surrounding application you can reuse. A starter can save integration work when its authentication and database assumptions fit; adapting it can cost more than adding Stripe to an app that already has those pieces.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Starting point What it provides Best fit Trade-off
Existing Next.js app You add the checkout endpoint, webhook handling, and billing persistence to the current architecture Your app already has authentication, a database, and established user records You must design and implement the billing-specific pieces that are not already present
Next.js SaaS Starter Its README describes Stripe Checkout, Stripe Customer Portal subscription management, a Postgres database, and production webhook setup You want a starting structure and its authentication/database choices match your project You may need to adapt or migrate its assumptions to fit an existing product

The starter is software, not a physical product. Neither approach removes the need to verify webhook signatures and define how billing events map to your application’s access rules.

What to check before launch

  • Checkout accepts a plan identifier that the server validates; it does not trust a client-supplied amount or arbitrary Price ID.
  • Only server code can read the Stripe secret key and webhook signing secret.
  • The success page does not independently grant access.
  • The webhook reads the raw body and validates the signature before acting on the event.
  • Your event handling is retry-safe, and your database can link Stripe records to the intended application user.
  • Local and live endpoints have the correct, separate signing secrets and event configuration.
  • Your application has explicit behavior for relevant subscription and payment state changes, including any delayed-payment events it supports.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.