Add Stripe Payments to Next.js with Server Actions (2026)
Set up Stripe payments in Next.js using Server Actions. Covers Checkout Sessions, webhooks, and payment verification with working code.
You have a Next.js app. You want to charge people money. The old way involved spinning up a separate API route, managing client-server state, and writing a bunch of fetch calls. With Server Actions in Next.js 15+, you can create a Stripe Checkout Session from a function that runs on the server — called directly from your component. No API route file. No fetch. Just a function.
This post walks through the entire setup: installing Stripe, creating a checkout session with a Server Action, handling webhooks, and verifying payments. Every code example works with Next.js 15+ and the App Router.
If you have already set up Stripe in a plain React app, this will feel familiar. The difference is that Server Actions remove an entire layer of boilerplate.
What You Need
Before writing code:
- A Stripe account (free to create)
- Your publishable key and secret key from the Stripe Dashboard under Developers > API Keys
- Next.js 15+ with the App Router (the default for new projects in 2026)
- Node.js 18+
Grab your test keys. Test mode lets you process fake transactions without charging anyone.
Step 1: Install the Packages
You need three packages:
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
stripe— the server-side Node SDK. Used in Server Actions and webhook handlers.@stripe/stripe-js— loads Stripe.js on the client for redirects and Embedded Checkout.@stripe/react-stripe-js— React components and hooks for Stripe Elements.
Step 2: Set Up Environment Variables
Add these to your .env.local file:
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
The NEXT_PUBLIC_ prefix exposes the publishable key to the browser. That is safe — publishable keys are meant to be public. The secret key and webhook secret stay server-side only.
Step 3: Create a Stripe Instance
Create a shared Stripe instance for your server-side code:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-12-18.acacia',
typescript: true,
});
Keep this file in your lib/ folder. Import it wherever you need server-side Stripe access.
Step 4: Create a Checkout Session with a Server Action
This is where Server Actions shine. Instead of creating an API route, writing a POST handler, and calling fetch from the client, you write a single function:
// app/actions/checkout.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
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`,
});
if (!session.url) {
throw new Error('Failed to create checkout session');
}
redirect(session.url);
}
The 'use server' directive at the top marks this as a Server Action. It runs on your server but can be called from a client component like a regular function. Next.js handles the network request behind the scenes.
For subscriptions, change mode: 'payment' to mode: 'subscription' and use a recurring price ID from your Stripe Dashboard.
Step 5: Build the Checkout Button
Now call the Server Action from a client component:
// app/components/CheckoutButton.tsx
'use client';
import { createCheckoutSession } from '@/app/actions/checkout';
import { useTransition } from 'react';
export function CheckoutButton({ priceId }: { priceId: string }) {
const [isPending, startTransition] = useTransition();
const handleCheckout = () => {
startTransition(async () => {
await createCheckoutSession(priceId);
});
};
return (
<button onClick={handleCheckout} disabled={isPending}>
{isPending ? 'Redirecting...' : 'Buy Now'}
</button>
);
}
When the user clicks “Buy Now”, the Server Action creates a Checkout Session and redirects them to Stripe’s hosted payment page. No intermediate API route needed.
useTransition keeps the UI responsive while the server call happens. The button shows a loading state so the user knows something is happening.
Step 6: Using Embedded Checkout (Optional)
Stripe is pushing Embedded Checkout hard in 2026. Instead of redirecting to a Stripe-hosted page, you embed the checkout form directly in your app. This keeps users on your site and gives you more control over the experience.
First, create a Server Action that returns the client secret instead of redirecting:
// app/actions/embedded-checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
export async function createEmbeddedCheckoutSession(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price: priceId,
quantity: 1,
},
],
ui_mode: 'embedded',
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
});
return { clientSecret: session.client_secret };
}
Then render it with Stripe’s EmbeddedCheckout component:
// app/components/EmbeddedCheckoutForm.tsx
'use client';
import { loadStripe } from '@stripe/stripe-js';
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from '@stripe/react-stripe-js';
import { createEmbeddedCheckoutSession } from '@/app/actions/embedded-checkout';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export function EmbeddedCheckoutForm({ priceId }: { priceId: string }) {
const fetchClientSecret = async () => {
const { clientSecret } = await createEmbeddedCheckoutSession(priceId);
return clientSecret!;
};
return (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
}
The Embedded Checkout form handles card input, validation, Apple Pay, Google Pay, and localization. You get all of that without building it yourself.
Step 7: Handle Webhooks
Stripe uses webhooks to tell your app when things happen — a payment succeeds, a subscription renews, a charge is disputed. You should never trust the client alone to confirm a payment. Webhooks are your source of truth.
Create a Route Handler for the webhook:
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// Fulfill the order
// Update your database, send confirmation email, etc.
console.log('Payment successful for session:', session.id);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
// Handle subscription changes
console.log('Subscription updated:', subscription.id);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
// Handle cancellation
console.log('Subscription cancelled:', subscription.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
Critical: Use request.text() to get the raw body, not request.json(). Stripe needs the raw body to verify the webhook signature. If you parse it first, the signature check will fail every time.
Step 8: Test Webhooks Locally
Stripe cannot reach localhost, so you need the Stripe CLI to forward events to your dev server:
# Install the Stripe CLI (if you haven't)
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhook events to your local server
stripe listen --forward-to localhost:3000/api/webhook
The CLI will print a webhook signing secret that starts with whsec_. Copy that into your .env.local as STRIPE_WEBHOOK_SECRET.
Now when you complete a test checkout, the CLI forwards the event to your local webhook handler. Use Stripe’s test card number 4242 4242 4242 4242 with any future expiry and any CVC.
Step 9: Verify Payments on the Success Page
After a successful payment, Stripe redirects to your success URL with a session_id parameter. Use that to verify the payment actually went through:
// app/success/page.tsx
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) {
redirect('/');
}
const session = await stripe.checkout.sessions.retrieve(session_id);
if (session.payment_status !== 'paid') {
redirect('/');
}
return (
<div>
<h1>Payment Successful</h1>
<p>Thank you for your purchase.</p>
<p>Order ID: {session.id}</p>
</div>
);
}
This is a server component, so the Stripe call happens on the server. The secret key never reaches the browser.
The Faster Path: Skip the Plumbing
If you are building an MVP and the last three sections made you think “I just want to charge people, not become a Stripe engineer” — that is a normal reaction.
Beag.io gives you auth and Stripe payments out of the box. You get login, signup, subscription management, and webhook handling without writing any of it yourself. If you already have auth set up in your Next.js app, Beag connects payments to your existing user accounts. It takes about five minutes instead of a weekend.
Not everyone needs that. If you want full control over the payment flow, keep reading.
Common Gotchas
Webhook signature fails in production. Make sure your production STRIPE_WEBHOOK_SECRET matches the webhook endpoint you configured in the Stripe Dashboard (not the CLI secret). The CLI secret is for local development only.
Server Action returns a serialization error. Server Actions can only return plain objects, not class instances. If you return a full Stripe session object, Next.js will fail to serialize it. Return only the fields you need: { clientSecret: session.client_secret }.
Checkout Session expires. Checkout Sessions expire after 24 hours by default. Do not store them long-term. Create a new one each time the user clicks “Buy.”
Double fulfillment. Your webhook handler should be idempotent. If Stripe retries a webhook (and it will), you should not fulfill the same order twice. Use the session.id or payment_intent as a unique key in your database.
The Full Flow
Here is what happens end to end:
- User clicks “Buy Now” in your app
- Your Server Action creates a Stripe Checkout Session
- User gets redirected to Stripe’s payment page (or sees Embedded Checkout)
- User enters card details and pays
- Stripe redirects to your success page
- Stripe sends a
checkout.session.completedwebhook to your server - Your webhook handler fulfills the order and updates your database
The webhook in step 6 is the authoritative confirmation. The success page in step 5 is just for user experience.
Wrapping Up
Server Actions make Stripe integration in Next.js noticeably cleaner than the old API route approach. You write less code, manage less state, and the mental model is simpler — call a function, things happen on the server.
The setup covered here handles one-time payments and subscriptions. For more complex billing (metered usage, tiered pricing, free trials), the same patterns apply — you just configure the Checkout Session differently.
If you want auth and payments handled for you so you can focus on building features, check out Beag.io. It wires up everything from login to Stripe subscriptions in minutes.
FAQ
Can I use Server Actions for Stripe in production?
Yes. Server Actions run on the server, so your Stripe secret key never reaches the client. They are production-ready in Next.js 15+ and are the recommended way to handle server-side mutations in the App Router.
Do I still need API routes for webhooks?
Yes. Webhooks are incoming HTTP requests from Stripe’s servers, not user-initiated actions. You need a Route Handler (app/api/webhook/route.ts) to receive them. Server Actions only work for requests that originate from your frontend.
What is the difference between Stripe Checkout and Stripe Elements?
Stripe Checkout is a prebuilt, hosted payment page (or embedded form) that Stripe manages for you. It handles card input, validation, 3D Secure, Apple Pay, and more. Stripe Elements gives you individual UI components (card number field, expiry field) that you style and assemble yourself. Checkout is faster to set up. Elements gives you more design control. For most MVPs, Checkout is the right choice.
How do I handle subscription billing with Server Actions?
Use the same createCheckoutSession Server Action but set mode: 'subscription' instead of mode: 'payment'. Create a recurring price in your Stripe Dashboard and pass that price ID. Stripe handles the billing cycle, invoicing, and payment retries. You listen for customer.subscription.updated and customer.subscription.deleted webhooks to sync status with your database.
How do I test without charging real cards?
Use Stripe’s test mode (toggle it in the Dashboard). All test API keys start with sk_test_ and pk_test_. Use the test card number 4242 4242 4242 4242 with any future expiry date and any three-digit CVC. For testing failed payments, use 4000 0000 0000 0002. For 3D Secure, use 4000 0025 0000 3155.
Ready to Make Money From Your SaaS?
Turn your SaaS into cash with Beag.io. Get started now!
Start 7-day free trial →