Building a checkout with Next.js and Stripe: a deep dive
We've all seen checkout flows break in production—a race condition here, a missing signature verification there, and suddenly your payment processing becomes unreliable. Building a checkout with Next.js and Stripe requires careful orchestration across the frontend, your backend, and Stripe's infrastructure to handle the full flow from cart to confirmed order. This article walks through a production-ready implementation that handles server-side price calculation, secure redirects, webhook verification, and order fulfillment.
Why a server-side checkout matters
The first instinct many teams have is to calculate prices on the client and pass them to Stripe. Don't do that. A user can modify their browser's localStorage, change network requests, or simply tamper with form data before submission. The correct approach is to generate a Checkout Session on the server, where your business logic and database are the source of truth.
In a real e-commerce scenario, you might have dynamic pricing based on user tier, inventory availability, or active promotions. Your server fetches the current state from your database, applies your rules, and then tells Stripe the exact amount to charge. This separation keeps your pricing logic secure and auditable.
Server action — Create a Checkout Session with server-side prices
Next.js Server Actions give us a clean way to call backend logic directly from the client. We'll create an action that accepts a cart, validates it, builds the session, and returns a redirect URL.
// app/actions/checkout.ts
'use server'
import Stripe from 'stripe'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
interface CartItem {
productId: string
quantity: number
}
export async function createCheckoutSession(cart: CartItem[]) {
// Authenticate the user
const session = await auth()
if (!session?.user?.id) {
throw new Error('Not authenticated')
}
// Fetch products from database to get current prices
const productIds = cart.map(item => item.productId)
const products = await db.product.findMany({
where: { id: { in: productIds } }
})
// Build line items with server-side prices
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = cart.map(
cartItem => {
const product = products.find(p => p.id === cartItem.productId)
if (!product) {
throw new Error(`Product ${cartItem.productId} not found`)
}
return {
price_data: {
currency: 'usd',
product_data: {
name: product.name,
images: [product.imageUrl]
},
unit_amount: product.priceInCents
},
quantity: cartItem.quantity
}
}
)
// Create session with metadata for tracking
const checkoutSession = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cart`,
customer_email: session.user.email,
metadata: {
userId: session.user.id,
cartHash: JSON.stringify(cart)
}
})
if (!checkoutSession.url) {
throw new Error('Failed to create checkout session')
}
return checkoutSession.url
}Several patterns matter here. First, we authenticate before doing anything—never process payments for unknown users. Second, we fetch product details from the database rather than trusting the client. Third, we include metadata that ties the Stripe session to your user and cart, which you'll use later when the webhook arrives. The metadata isn't sensitive pricing data; it's just identifiers for matching the payment event back to your records.
Redirect — Send the customer to Stripe Checkout
Your checkout page calls the server action and redirects to Stripe's hosted checkout. This is where Stripe handles PCI compliance for you—your server never touches the card data.
// app/checkout/page.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useCart } from '@/hooks/useCart'
import { createCheckoutSession } from '@/app/actions/checkout'
import { useState } from 'react'
export default function CheckoutPage() {
const router = useRouter()
const { cart } = useCart()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleCheckout = async () => {
setIsLoading(true)
setError('')
try {
const checkoutUrl = await createCheckoutSession(cart)
router.push(checkoutUrl)
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed')
setIsLoading(false)
}
}
return (
<div>
<h1>Review your order</h1>
<ul>
{cart.map(item => (
<li>
{item.productId} x {item.quantity}
</li>
))}
</ul>
{error && <p>{error}</p>}
{isLoading ? 'Processing...' : 'Proceed to Stripe'}
</div>
)
}The flow is intentionally simple from the user's perspective. They click "Proceed to Stripe," and Next.js calls your server action. If it succeeds, they're redirected to Stripe's hosted page. If something goes wrong—inventory out of stock, database error, authentication issue—they stay on your page and see an error message.
Webhook route — Verify signatures with the raw body
After the customer completes payment on Stripe's checkout, Stripe sends a webhook to your application. This webhook is how you learn that the payment succeeded and should fulfill the order. The critical piece is signature verification: you must confirm that the webhook actually came from Stripe, not from someone making up fake events.
Stripe signs webhooks using your endpoint secret. The signature is an HMAC-SHA256 hash of the raw request body. This is why you cannot parse the body as JSON first and then verify the signature—you need the exact raw bytes.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
// Get raw body for signature verification
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return new Response('Invalid signature', { status: 401 })
}
// Handle the checkout.session.completed event
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
try {
await fulfillOrder(session)
} catch (err) {
console.error('Order fulfillment failed:', err)
// Return 500 so Stripe retries
return new Response('Fulfillment failed', { status: 500 })
}
}
return new Response('Received', { status: 200 })
}
async function fulfillOrder(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId
const cartHash = session.metadata?.cartHash
if (!userId || !cartHash) {
throw new Error('Missing metadata')
}
// Parse cart from metadata
const cart: Array<{ productId: string; quantity: number }> = JSON.parse(cartHash)
// Create order record
const order = await db.order.create({
data: {
userId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
totalAmountCents: session.amount_total!,
status: 'confirmed',
items: {
create: cart.map(item => ({
productId: item.productId,
quantity: item.quantity
}))
}
}
})
// You could queue async tasks here (emails, fulfillment, etc.)
// For now, just log
console.log(`Order ${order.id} created for user ${userId}`)
}The signature verification happens via stripe.webhooks.constructEvent, which takes the raw body and signature header and either returns a verified event or throws an error. This is your security boundary—if the signature doesn't match, you reject the webhook entirely. Never process unverified webhooks.
One important pattern: if fulfillment fails (database error, service timeout), we return HTTP 500. This tells Stripe to retry the webhook later. If we returned 200, Stripe would assume success and never send the event again. Idempotency is built in via the order record check—if the same Stripe session appears twice, the second create will fail with a unique constraint violation, so you'll need to wrap this in error handling that gracefully handles duplicates.
Confirmation — Fulfill the order and show the result
Once the webhook has been processed and the order created, the customer can view their confirmation. The success page queries Stripe to get the session details and displays the order summary.
// app/checkout/success/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Stripe from 'stripe'
export default function SuccessPage() {
const searchParams = useSearchParams()
const sessionId = searchParams.get('session_id')
const [session, setSession] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!sessionId) return
const fetchSession = async () => {
try {
const res = await fetch(`/api/checkout-session?sessionId=${sessionId}`)
const data = await res.json()
setSession(data)
} catch (err) {
console.error('Failed to fetch session:', err)
} finally {
setLoading(false)
}
}
fetchSession()
}, [sessionId])
if (loading) return <p>Loading...</p>
if (!session) return <p>Session not found</p>
return (
<div>
<h1>Order Confirmed</h1>
<p>Thank you for your purchase!</p>
<p>Order ID: {session.payment_intent}</p>
<p>Amount: ${(session.amount_total! / 100).toFixed(2)}</p>
<p>Email: {session.customer_email}</p>
</div>
)
}We create a simple API route that queries Stripe for the session details:
// app/api/checkout-session/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return new Response('Missing sessionId', { status: 400 })
}
try {
const session = await stripe.checkout.sessions.retrieve(sessionId)
return Response.json(session)
} catch (err) {
return new Response('Session not found', { status: 404 })
}
}This fetches the session from Stripe to confirm payment and display order details. In production, you'd typically fetch from your own database instead of Stripe for speed and reliability, but querying Stripe directly is a good fallback if your webhook hasn't processed yet.
Common mistakes
- Parsing the request body before verifying the webhook signature. Always use the raw body for signature verification. If you call
req.json()first, the raw body is consumed and you cannot verify the signature. - Trusting client-side prices. Always fetch product data and prices from your server, never from form inputs or localStorage.
- Not handling webhook retries. Stripe retries failed webhooks (ones that returned 5xx or timed out). Your fulfillment logic must be idempotent or must check if the order already exists before creating it.
- Returning 200 for failed fulfillment. If your database is down or an email service fails, return 5xx so Stripe retries. Return 200 only when you've successfully processed the event or determined it doesn't apply to you.
- Exposing your webhook secret in client-side code. The webhook secret should only live in environment variables on your server. Never send it to the browser.
Key takeaways
- Build the checkout session on the server with database-sourced prices to prevent tampering and ensure accuracy.
- Redirect to Stripe's hosted checkout page for PCI compliance and a smoother user experience.
- Always verify webhook signatures using the raw request body before processing any payment events.
- Return HTTP 500 from your webhook route if fulfillment fails, so Stripe retries the event.
- Design your order creation logic to be idempotent, handling duplicate webhook deliveries gracefully.
- Use webhook metadata to correlate Stripe events back to your application's user and cart records.






Comments
Loading comments…