Stripe is the standard for online payments. Here is how to build a full payment flow in Next.js.
Setup
pnpm add stripe @stripe/stripe-js @stripe/react-stripe-js
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});
// 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;
}
One-Time Payment (Checkout Session)
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(request: NextRequest) {
const { priceId, quantity = 1 } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity }],
success_url: `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${request.nextUrl.origin}/checkout/cancel`,
metadata: { source: "website" },
});
return NextResponse.json({ url: session.url });
}
Subscription Checkout
// app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(request: NextRequest) {
const { priceId, customerId } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
customer: customerId || undefined,
customer_creation: customerId ? undefined : "always",
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
metadata: { source: "website" },
},
success_url: `${request.nextUrl.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${request.nextUrl.origin}/pricing`,
allow_promotion_codes: true,
});
return NextResponse.json({ url: session.url });
}
Webhook Handler
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed");
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 handleCheckoutComplete(session);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdate(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
}
return NextResponse.json({ received: true });
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string | null;
// Update your database with the subscription info
// await db.update(users).set({ stripeCustomerId: customerId, subscriptionId }).where(...)
console.log("Checkout complete:", { customerId, subscriptionId });
}
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const status = subscription.status;
const customerId = subscription.customer as string;
// Update subscription status in your database
console.log("Subscription updated:", { customerId, status });
}
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
// Revoke access
console.log("Subscription canceled:", customerId);
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
// Send notification email, show banner, etc.
console.log("Payment failed:", customerId);
}
Customer Portal
// app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(request: NextRequest) {
const { customerId } = await request.json();
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${request.nextUrl.origin}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}
Pricing Page Component
"use client";
import { useState } from "react";
import { getStripe } from "@/lib/stripe-client";
interface Plan {
name: string;
monthlyPriceId: string;
yearlyPriceId: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
popular?: boolean;
}
const plans: Plan[] = [
{
name: "Starter",
monthlyPriceId: "price_starter_monthly",
yearlyPriceId: "price_starter_yearly",
monthlyPrice: 19,
yearlyPrice: 190,
features: ["5 projects", "Basic analytics", "Email support"],
},
{
name: "Pro",
monthlyPriceId: "price_pro_monthly",
yearlyPriceId: "price_pro_yearly",
monthlyPrice: 49,
yearlyPrice: 490,
features: ["Unlimited projects", "Advanced analytics", "Priority support", "Custom domains"],
popular: true,
},
];
export function PricingCards() {
const [annual, setAnnual] = useState(false);
const [loading, setLoading] = useState<string | null>(null);
async function handleSubscribe(plan: Plan) {
setLoading(plan.name);
try {
const priceId = annual ? plan.yearlyPriceId : plan.monthlyPriceId;
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
const stripe = await getStripe();
if (url) window.location.href = url;
} finally {
setLoading(null);
}
}
return (
<div>
<div className="flex items-center justify-center gap-3 mb-8">
<span className={annual ? "text-muted-foreground" : "font-medium"}>Monthly</span>
<button
onClick={() => setAnnual(!annual)}
className={`relative w-12 h-6 rounded-full transition-colors ${
annual ? "bg-primary" : "bg-muted"
}`}
role="switch"
aria-checked={annual}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
annual ? "translate-x-6" : ""
}`}
/>
</button>
<span className={annual ? "font-medium" : "text-muted-foreground"}>
Annual <span className="text-green-600 text-xs">Save 20%</span>
</span>
</div>
<div className="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{plans.map((plan) => (
<div
key={plan.name}
className={`border rounded-xl p-6 ${
plan.popular ? "border-primary ring-1 ring-primary" : ""
}`}
>
{plan.popular && (
<span className="text-xs font-medium bg-primary text-primary-foreground px-2 py-0.5 rounded-full">
Most popular
</span>
)}
<h3 className="text-xl font-bold mt-2">{plan.name}</h3>
<p className="text-3xl font-bold mt-2">
${annual ? plan.yearlyPrice : plan.monthlyPrice}
<span className="text-sm font-normal text-muted-foreground">
/{annual ? "year" : "month"}
</span>
</p>
<ul className="mt-4 space-y-2">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
/>
</svg>
{f}
</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan)}
disabled={loading !== null}
className="mt-6 w-full py-2 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50"
>
{loading === plan.name ? "Redirecting..." : "Get started"}
</button>
</div>
))}
</div>
</div>
);
}
Need Payment Integration?
We build complete payment systems with Stripe including subscriptions, invoicing, and billing portals. Contact us to get started.