How to Add Stripe to a Next.js Site (2026 Step-by-Step)
Stripe is the de-facto payments layer for Next.js apps in 2026. The official Stripe + Next.js setup is straightforward but has six moving parts: products and prices in the dashboard, environment variables, a checkout API route, webhook signature verification, the customer portal, and the success/cancel return URLs. This guide covers every one.
What you'll build
By the end of this guide your Next.js site will: (1) let users click a 'Subscribe' button and complete checkout on Stripe, (2) receive webhook events when subscriptions are created/updated/canceled, (3) persist subscription state in your database, and (4) give users a 'Manage billing' link that opens Stripe's hosted customer portal.
The stack: Next.js App Router, `@stripe/stripe-js` on the client, `stripe` on the server, and your existing database (Postgres via Prisma is the most common). Total setup time is 30–60 minutes for someone who's done it before, 2–3 hours for someone doing it for the first time.
Step 1 — Set up products and prices in the Stripe dashboard
In the Stripe dashboard, go to Products → Add product. Create one product per offering (e.g. 'Pro plan'). For each product, add one or more prices (e.g. monthly $49, yearly $470). Stripe gives each price a unique ID like `price_1Q2x3yABCD`. Copy these IDs — you'll need them in your environment variables.
Use Stripe's test mode (toggle in the dashboard) until you're ready to go live. Test mode prices have IDs prefixed `price_` like live ones; switching modes uses different keys and different IDs. Don't mix them in the same .env file.
Step 2 — Environment variables
Your `.env.local` (and Vercel env config) needs five values: `STRIPE_SECRET_KEY` (sk_test_... or sk_live_...), `STRIPE_WEBHOOK_SECRET` (whsec_..., from the Webhooks section of the dashboard after you create the webhook in step 4), `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` (pk_test_... or pk_live_...), the price IDs for each plan (e.g. `STRIPE_PRO_MONTHLY_PRICE_ID`), and `NEXT_PUBLIC_APP_URL` for the return URLs.
Production keys live in your hosting provider's environment variables (Vercel project settings), not in your repo. Never commit `STRIPE_SECRET_KEY` to git.
Step 3 — Build the checkout API route
Create `app/api/billing/checkout/route.ts`. The handler accepts a POST with the plan + interval, looks up the price ID, creates a Stripe Checkout session, and returns the session URL. The client then `window.location.href`s to that URL.
Stripe Checkout creates the customer record for you on first payment. Pass `customer_email` if you have it from your auth system; otherwise leave it out and Stripe asks the user during checkout. For subscriptions, set `mode: 'subscription'` and pass `line_items: [{ price: priceId, quantity: 1 }]`.
Critical: pass `success_url` and `cancel_url` pointing back to your site (e.g. `${APP_URL}/dashboard?checkout=success`). Append `session_id={CHECKOUT_SESSION_ID}` so the success page can verify the payment server-side.
Step 4 — Set up the webhook
In the Stripe dashboard, go to Developers → Webhooks → Add endpoint. Set the URL to `${APP_URL}/api/billing/webhook` (use Stripe's CLI for local testing). Subscribe to events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`. Stripe gives you a signing secret (`whsec_...`) — store it in `STRIPE_WEBHOOK_SECRET`.
Your `app/api/billing/webhook/route.ts` handler MUST verify the signature using `stripe.webhooks.constructEvent(rawBody, signature, secret)`. Without signature verification, attackers can forge subscription events. Use `req.text()` to get the raw body — DO NOT use `req.json()`, which corrupts the signature.
On `checkout.session.completed` or `customer.subscription.created/updated`, upsert a Subscription row in your database keyed on the user. On `customer.subscription.deleted`, set status to canceled. On `invoice.payment_failed`, set status to past_due and email the user.
Step 5 — Wire the customer portal
Stripe's hosted customer portal handles everything users want to do after subscribing: update card, change plan, view invoices, cancel. You don't have to build any of this yourself.
Create `app/api/billing/portal/route.ts`. The handler looks up the user's `stripe_customer_id` from your database, calls `stripe.billingPortal.sessions.create({ customer: customerId, return_url: ... })`, and returns the session URL. Your dashboard's 'Manage billing' button POSTs to this endpoint then redirects to the URL.
Step 6 — Going live
Before flipping to live mode: complete Stripe's account verification (business details, bank account), test the full flow in test mode with multiple cards (Stripe has special test card numbers for declined payments, 3DS challenges, etc.), and confirm webhook events arrive correctly (check the dashboard's webhook log).
When you switch to live keys, you'll need to: (1) replace test price IDs with live ones (different IDs!), (2) create a new live-mode webhook with a new signing secret, (3) update all Stripe env vars in production. Stripe deliberately separates test and live mode to prevent accidents.
Common gotchas
Webhooks fail silently if you use `req.json()` instead of `req.text()` — signature verification needs the raw body bytes, and `.json()` reformats them.
If your webhook handler is slow (>5s), Stripe retries — leading to duplicate processing. Acknowledge fast (return 200), then process asynchronously, or be idempotent.
Test the cancel-at-period-end flow: many bugs hide there. Use Stripe Test Clocks to simulate the time advancing past `current_period_end` without waiting a month.
Subscription quantity defaults to 1; for per-seat pricing you need to pass `quantity` and update it on team-size changes.
How to do it
- 1
Add products and prices in Stripe dashboard
Create one product per plan; add prices (monthly + yearly) under each. Copy the price IDs (price_...) for use in env vars.
- 2
Add Stripe env vars
Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, plus a price ID env var per plan. Use test mode keys until you've validated the flow.
- 3
Build the checkout route
Create app/api/billing/checkout/route.ts that creates a Stripe Checkout session for the requested plan and returns the URL. Pass success_url and cancel_url back to your site.
- 4
Create + verify the webhook
Add a webhook endpoint in Stripe pointing at /api/billing/webhook. Subscribe to checkout.session.completed and subscription.* events. In your handler, verify the signature with stripe.webhooks.constructEvent before processing.
- 5
Persist subscriptions to your DB
On the relevant webhook events, upsert a Subscription row keyed on the user. Track plan, status, current_period_end, and cancel_at_period_end.
- 6
Wire the customer portal
Build /api/billing/portal that returns a billingPortal session URL. Add a 'Manage billing' button on your account page that POSTs to it and redirects.
- 7
Test the full flow
Use Stripe's test cards (4242 4242 4242 4242 succeeds, 4000 0000 0000 0002 declines). Confirm webhooks fire correctly. Use Test Clocks to simulate recurring billing.
- 8
Go live
Verify your Stripe account. Swap test keys + price IDs for live ones. Create a new live-mode webhook with a new signing secret. Update production env vars.
Frequently asked questions
Do I need to use Stripe Checkout vs Stripe Elements?
For most sites — Stripe Checkout. It's hosted by Stripe, handles all the edge cases (3DS, declined cards, etc.), and removes PCI compliance burden from your server. Stripe Elements is the right answer when you need a custom checkout form embedded in your page (matching your branding) and you're willing to handle more compliance complexity.
How do I test webhooks locally?
Install the Stripe CLI: `stripe listen --forward-to localhost:3000/api/billing/webhook`. It tunnels Stripe's events to your local server. The CLI prints a temporary signing secret you can use for local STRIPE_WEBHOOK_SECRET.
What about refunds?
Refunds happen in the Stripe dashboard (one click) or via the API (`stripe.refunds.create`). Your webhook receives `charge.refunded`, which you handle the same way as other subscription-state changes.
Should I store the full Stripe customer object in my DB?
No — store just the `stripe_customer_id` and `stripe_subscription_id`. Fetch the rest from Stripe when needed (cached briefly if performance matters). Storing the whole Stripe customer in your DB means you have to keep it in sync, which gets painful.
Ready to build?
Try InBuild for free — describe what you want, get a complete site in 30 seconds, export the code anytime.
Start freeMore guides
The Ultimate Guide to AI Website Builders (2026)
How AI website builders work, what they're good at, where they fall short, and how to pick one — Lovable, v0, Bolt, Replit, InBuild compared with honest tradeoffs.
ReadTutorialThe Complete Guide to SaaS Landing Pages (2026)
Anatomy of a SaaS landing page that converts: hero, social proof, features, pricing, FAQ. What every section does, what good looks like, and how to build it in 30 seconds.
Read