Prerequisites
Before we begin, make sure you have the following in place:
- A Stripe account — sign up at dashboard.stripe.com if you do not have one yet
- A Next.js 15 project using the App Router (not Pages Router)
- Node.js 18+ installed
- Basic familiarity with React Server Components and Route Handlers
We will build a complete checkout flow: creating products in Stripe, initiating checkout sessions from the server, redirecting users to Stripe's hosted checkout page, and handling webhooks for payment confirmation.
Installing Dependencies
You need two packages: the Stripe Node.js SDK for server-side operations, and the Stripe.js browser library for client-side redirects.
npm install stripe @stripe/stripe-jsThe stripe package is used in server-side code (Route Handlers, Server Components) and should never be imported in client components. The @stripe/stripe-js package is the lightweight client-side loader.
Environment Variable Setup
Create or update your .env.local file with your Stripe keys. You can find these in the Stripe Dashboard under Developers > API keys:
# .env.local
# Server-side only (never exposed to the browser)
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Client-side (prefixed with NEXT_PUBLIC_)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_hereAdd type definitions to your environment file for TypeScript safety:
// environment.d.ts
declare namespace NodeJS {
interface ProcessEnv {
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string;
}
}Important: Never commit .env.local to version control. Ensure it is listed in your .gitignore.
Creating the Stripe Instance
Create a shared Stripe instance for server-side use:
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});And a client-side loader:
// lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";
let stripePromise: ReturnType<typeof loadStripe>;
export function getStripe() {
if (!stripePromise) {
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
);
}
return stripePromise;
}Creating Products in Stripe Dashboard
Before writing any checkout code, set up your products in the Stripe Dashboard:
- Navigate to Products in your Stripe Dashboard
- Click Add product
- Fill in the product name, description, and image
- Set the pricing — choose one-time or recurring
- Copy the Price ID (starts with
price_) — you will need this in your code
You can also create products programmatically, but for most use cases, the dashboard is faster and more convenient during development.
Store your price IDs in a configuration file:
// config/products.ts
export const PRODUCTS = {
starter: {
name: "Starter Plan",
priceId: "price_1234567890",
features: ["5 projects", "Basic analytics", "Email support"],
},
pro: {
name: "Pro Plan",
priceId: "price_0987654321",
features: [
"Unlimited projects",
"Advanced analytics",
"Priority support",
"Custom domain",
],
},
} as const;Server-Side: Creating a Checkout Session
Create a Route Handler that generates a Stripe Checkout Session and returns the URL for redirection:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { z } from "zod";
const checkoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.number().int().positive().default(1),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { priceId, quantity } = checkoutSchema.parse(body);
const origin = request.headers.get("origin") || "http://localhost:3000";
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity,
},
],
success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/checkout/cancel`,
metadata: {
priceId,
},
});
return NextResponse.json({ url: session.url }, { status: 200 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request data", details: error.errors },
{ status: 400 }
);
}
console.error("Checkout session error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}Key options for checkout.sessions.create:
mode— Use"payment"for one-time purchases or"subscription"for recurring billingsuccess_url— Where to redirect after successful payment.{CHECKOUT_SESSION_ID}is replaced by Stripe automaticallycancel_url— Where to redirect if the user cancelsmetadata— Custom key-value pairs stored with the session (useful for webhooks)
Client-Side: Checkout Button with Redirect
Create a client component that calls your API route and redirects to Stripe Checkout:
// components/CheckoutButton.tsx
"use client";
import { useState } from "react";
interface CheckoutButtonProps {
priceId: string;
label?: string;
}
export function CheckoutButton({
priceId,
label = "Buy Now",
}: CheckoutButtonProps) {
const [isLoading, setIsLoading] = useState(false);
async function handleCheckout() {
setIsLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId, quantity: 1 }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Checkout failed");
}
// Redirect to Stripe's hosted checkout page
window.location.href = data.url;
} catch (error) {
console.error("Checkout error:", error);
alert("Something went wrong. Please try again.");
} finally {
setIsLoading(false);
}
}
return (
<button
onClick={handleCheckout}
disabled={isLoading}
className="rounded-lg bg-indigo-600 px-6 py-3 text-white font-medium
hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
aria-busy={isLoading}
>
{isLoading ? "Redirecting..." : label}
</button>
);
}Use this component on your pricing page:
// app/pricing/page.tsx
import { CheckoutButton } from "@/components/CheckoutButton";
import { PRODUCTS } from "@/config/products";
export default function PricingPage() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto p-8">
{Object.entries(PRODUCTS).map(([key, product]) => (
<div key={key} className="border rounded-xl p-6 shadow-sm">
<h2 className="text-2xl font-bold">{product.name}</h2>
<ul className="mt-4 space-y-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="text-green-500">✓</span>
{feature}
</li>
))}
</ul>
<div className="mt-6">
<CheckoutButton priceId={product.priceId} />
</div>
</div>
))}
</div>
);
}Webhooks: Verifying Events with Endpoint Secret
Webhooks are how Stripe notifies your application about events — payments completed, subscriptions renewed, refunds issued. This is the most critical part of any Stripe integration.
First, create a webhook endpoint:
// app/api/webhooks/stripe/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 stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleSuccessfulPayment(session);
break;
}
case "payment_intent.payment_failed": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handleFailedPayment(paymentIntent);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true }, { status: 200 });
}
async function handleSuccessfulPayment(session: Stripe.Checkout.Session) {
const customerEmail = session.customer_details?.email;
const metadata = session.metadata;
console.log(`Payment successful for ${customerEmail}`);
// Fulfill the order:
// - Update database
// - Send confirmation email
// - Grant access to purchased content
}
async function handleFailedPayment(paymentIntent: Stripe.PaymentIntent) {
console.error(
`Payment failed: ${paymentIntent.id}, reason: ${paymentIntent.last_payment_error?.message}`
);
// Handle failure:
// - Notify the user
// - Log for support follow-up
}Critical: The webhook route must read the raw request body as text (not JSON) for signature verification. This is why we use request.text() instead of request.json().
Testing Webhooks Locally
Use the Stripe CLI to forward webhook events to your local server:
# Install the Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward events to your local webhook endpoint
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe CLI will output a webhook signing secret (starting with whsec_). Use this as your STRIPE_WEBHOOK_SECRET during development.
Handling Successful and Failed Payments
Create dedicated pages for post-checkout states:
// app/checkout/success/page.tsx
import { stripe } from "@/lib/stripe";
interface SuccessPageProps {
searchParams: Promise<{ session_id?: string }>;
}
export default async function SuccessPage({ searchParams }: SuccessPageProps) {
const { session_id } = await searchParams;
if (!session_id) {
return <p>Invalid session.</p>;
}
const session = await stripe.checkout.sessions.retrieve(session_id);
return (
<div className="max-w-lg mx-auto p-8 text-center">
<h1 className="text-3xl font-bold text-green-600">
Payment Successful
</h1>
<p className="mt-4 text-gray-600">
Thank you for your purchase,{" "}
{session.customer_details?.name || "valued customer"}!
</p>
<p className="mt-2 text-sm text-gray-500">
A confirmation email has been sent to{" "}
{session.customer_details?.email}.
</p>
</div>
);
}// app/checkout/cancel/page.tsx
import Link from "next/link";
export default function CancelPage() {
return (
<div className="max-w-lg mx-auto p-8 text-center">
<h1 className="text-3xl font-bold text-gray-800">
Checkout Cancelled
</h1>
<p className="mt-4 text-gray-600">
Your payment was not processed. No charges were made.
</p>
<Link
href="/pricing"
className="mt-6 inline-block text-indigo-600 hover:underline"
>
Return to pricing
</Link>
</div>
);
}Test Mode vs Production
Stripe provides a complete test environment that mirrors production without processing real payments:
- Test keys start with
sk_test_andpk_test_ - Live keys start with
sk_live_andpk_live_
Use these test card numbers during development:
4242 4242 4242 4242— Successful payment4000 0000 0000 0002— Declined card4000 0000 0000 3220— Requires 3D Secure authentication
Any future expiry date and any 3-digit CVC will work with test cards.
When you are ready to go live:
- Complete your Stripe account activation (identity verification, bank account)
- Replace test keys with live keys in your production environment variables
- Set up a production webhook endpoint in the Stripe Dashboard
- Test the complete flow once with a real card (you can refund immediately)
Security Best Practices
Payment integrations demand careful attention to security. Follow these practices:
- Never expose your secret key. The
STRIPE_SECRET_KEYshould only be used in server-side code. It must not appear in client bundles or be prefixed withNEXT_PUBLIC_. - Always verify webhook signatures. The
constructEventmethod ensures the webhook payload genuinely came from Stripe. Never skip this verification. - Make webhooks idempotent. Stripe may send the same event multiple times. Use the event ID or session ID to check if you have already processed it before taking action.
- Validate on the server. Never trust price amounts or product selections from the client. Always reference server-side price IDs that you control.
- Use Content Security Policy headers. Allow Stripe's domains (
js.stripe.com,api.stripe.com) in your CSP configuration to prevent mixed-content issues. - Enable Stripe Radar. Stripe's built-in fraud detection is free and blocks many fraudulent transactions automatically. Review its settings in your dashboard.
- Log webhook events. Keep a log of all webhook events for debugging and audit purposes. This is invaluable when diagnosing payment issues in production.
Stripe's hosted checkout page handles PCI compliance for you. By redirecting users to Stripe rather than collecting card details on your own server, you avoid the complexity and liability of handling sensitive payment data directly.