Stripe Checkout
One-time payments with Stripe Checkout.
Dependencies#
npm install stripe @stripe/stripe-jsEnvironment Variables#
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000Stripe Client Setup#
1// lib/stripe.ts
2import Stripe from 'stripe';
3
4export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
5 apiVersion: '2024-11-20.acacia',
6 typescript: true,
7});1// lib/stripe-client.ts
2import { loadStripe } from '@stripe/stripe-js';
3
4export const stripePromise = loadStripe(
5 process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
6);Create Checkout Session#
1// app/api/checkout/route.ts
2import { auth } from '@clerk/nextjs/server';
3import { NextRequest, NextResponse } from 'next/server';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6
7export async function POST(request: NextRequest) {
8 const { userId } = await auth();
9 if (!userId) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12
13 const body = await request.json();
14 const { priceId, quantity = 1 } = body;
15
16 // Get or create Stripe customer
17 const user = await prisma.user.findUnique({
18 where: { clerkId: userId },
19 });
20
21 let customerId = user?.stripeCustomerId;
22
23 if (!customerId) {
24 const customer = await stripe.customers.create({
25 email: user?.email,
26 metadata: { userId },
27 });
28 customerId = customer.id;
29
30 await prisma.user.update({
31 where: { clerkId: userId },
32 data: { stripeCustomerId: customerId },
33 });
34 }
35
36 // Create checkout session
37 const session = await stripe.checkout.sessions.create({
38 customer: customerId,
39 mode: 'payment',
40 payment_method_types: ['card'],
41 line_items: [
42 {
43 price: priceId,
44 quantity,
45 },
46 ],
47 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
48 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/cancel`,
49 metadata: {
50 userId,
51 },
52 });
53
54 return NextResponse.json({ sessionId: session.id, url: session.url });
55}Checkout Button Component#
1// components/CheckoutButton.tsx
2'use client';
3
4import { useState } from 'react';
5import { stripePromise } from '@/lib/stripe-client';
6
7interface CheckoutButtonProps {
8 priceId: string;
9 quantity?: number;
10 children: React.ReactNode;
11 className?: string;
12}
13
14export function CheckoutButton({
15 priceId,
16 quantity = 1,
17 children,
18 className,
19}: CheckoutButtonProps) {
20 const [loading, setLoading] = useState(false);
21
22 const handleCheckout = async () => {
23 setLoading(true);
24
25 try {
26 const response = await fetch('/api/checkout', {
27 method: 'POST',
28 headers: { 'Content-Type': 'application/json' },
29 body: JSON.stringify({ priceId, quantity }),
30 });
31
32 const { sessionId, url } = await response.json();
33
34 // Option 1: Redirect to Stripe hosted page
35 if (url) {
36 window.location.href = url;
37 return;
38 }
39
40 // Option 2: Use Stripe.js redirect
41 const stripe = await stripePromise;
42 const { error } = await stripe!.redirectToCheckout({ sessionId });
43
44 if (error) {
45 console.error('Checkout error:', error);
46 }
47 } catch (error) {
48 console.error('Checkout failed:', error);
49 } finally {
50 setLoading(false);
51 }
52 };
53
54 return (
55 <button
56 onClick={handleCheckout}
57 disabled={loading}
58 className={className}
59 >
60 {loading ? 'Loading...' : children}
61 </button>
62 );
63}Product Display#
1// components/ProductCard.tsx
2import { CheckoutButton } from './CheckoutButton';
3
4interface Product {
5 id: string;
6 name: string;
7 description: string;
8 price: number;
9 priceId: string;
10 image?: string;
11}
12
13export function ProductCard({ product }: { product: Product }) {
14 return (
15 <div className="border rounded-xl p-6 bg-white dark:bg-gray-900">
16 {product.image && (
17 <img
18 src={product.image}
19 alt={product.name}
20 className="w-full h-48 object-cover rounded-lg mb-4"
21 />
22 )}
23 <h3 className="text-xl font-semibold">{product.name}</h3>
24 <p className="text-gray-600 dark:text-gray-400 mt-2">
25 {product.description}
26 </p>
27 <div className="mt-4 flex items-center justify-between">
28 <span className="text-2xl font-bold">
29 ${(product.price / 100).toFixed(2)}
30 </span>
31 <CheckoutButton
32 priceId={product.priceId}
33 className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
34 >
35 Buy Now
36 </CheckoutButton>
37 </div>
38 </div>
39 );
40}Webhook Handler#
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { stripe } from '@/lib/stripe';
4import { prisma } from '@/lib/prisma';
5import Stripe from 'stripe';
6
7export async function POST(request: NextRequest) {
8 const body = await request.text();
9 const signature = request.headers.get('stripe-signature')!;
10
11 let event: Stripe.Event;
12
13 try {
14 event = stripe.webhooks.constructEvent(
15 body,
16 signature,
17 process.env.STRIPE_WEBHOOK_SECRET!
18 );
19 } catch (err: any) {
20 console.error('Webhook signature verification failed:', err.message);
21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
22 }
23
24 switch (event.type) {
25 case 'checkout.session.completed': {
26 const session = event.data.object as Stripe.Checkout.Session;
27
28 // Record the purchase
29 await prisma.purchase.create({
30 data: {
31 stripeSessionId: session.id,
32 stripeCustomerId: session.customer as string,
33 userId: session.metadata?.userId!,
34 amount: session.amount_total!,
35 currency: session.currency!,
36 status: 'completed',
37 },
38 });
39
40 // Fulfill the order (e.g., grant access, send email)
41 await fulfillOrder(session);
42 break;
43 }
44
45 case 'payment_intent.payment_failed': {
46 const paymentIntent = event.data.object as Stripe.PaymentIntent;
47 console.error('Payment failed:', paymentIntent.id);
48 break;
49 }
50 }
51
52 return NextResponse.json({ received: true });
53}
54
55async function fulfillOrder(session: Stripe.Checkout.Session) {
56 const userId = session.metadata?.userId;
57 if (!userId) return;
58
59 // Example: Grant product access
60 await prisma.user.update({
61 where: { clerkId: userId },
62 data: {
63 purchasedProducts: {
64 push: session.metadata?.productId,
65 },
66 },
67 });
68
69 // Send confirmation email
70 // await sendPurchaseConfirmationEmail(userId, session);
71}Success Page#
1// app/checkout/success/page.tsx
2import { stripe } from '@/lib/stripe';
3import { redirect } from 'next/navigation';
4import Link from 'next/link';
5import { CheckCircle } from 'lucide-react';
6
7export default async function SuccessPage({
8 searchParams,
9}: {
10 searchParams: { session_id?: string };
11}) {
12 if (!searchParams.session_id) {
13 redirect('/');
14 }
15
16 const session = await stripe.checkout.sessions.retrieve(
17 searchParams.session_id
18 );
19
20 if (session.payment_status !== 'paid') {
21 redirect('/checkout/cancel');
22 }
23
24 return (
25 <div className="min-h-screen flex items-center justify-center">
26 <div className="text-center">
27 <CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
28 <h1 className="text-3xl font-bold mb-2">Payment Successful!</h1>
29 <p className="text-gray-600 mb-6">
30 Thank you for your purchase. You will receive a confirmation email shortly.
31 </p>
32 <Link
33 href="/dashboard"
34 className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
35 >
36 Go to Dashboard
37 </Link>
38 </div>
39 </div>
40 );
41}Cancel Page#
1// app/checkout/cancel/page.tsx
2import Link from 'next/link';
3import { XCircle } from 'lucide-react';
4
5export default function CancelPage() {
6 return (
7 <div className="min-h-screen flex items-center justify-center">
8 <div className="text-center">
9 <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
10 <h1 className="text-3xl font-bold mb-2">Payment Cancelled</h1>
11 <p className="text-gray-600 mb-6">
12 Your payment was cancelled. No charges were made.
13 </p>
14 <Link
15 href="/pricing"
16 className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
17 >
18 Return to Pricing
19 </Link>
20 </div>
21 </div>
22 );
23}Database Schema#
1// prisma/schema.prisma
2model User {
3 id String @id @default(cuid())
4 clerkId String @unique
5 email String
6 stripeCustomerId String? @unique
7 purchasedProducts String[]
8
9 purchases Purchase[]
10
11 @@index([stripeCustomerId])
12}
13
14model Purchase {
15 id String @id @default(cuid())
16 stripeSessionId String @unique
17 stripeCustomerId String
18 userId String
19 user User @relation(fields: [userId], references: [clerkId])
20 amount Int
21 currency String
22 status String
23
24 createdAt DateTime @default(now())
25
26 @@index([userId])
27 @@index([stripeCustomerId])
28}