Chapter 2: Next.js Integration with Stripe Checkout
In this chapter, we'll learn how to use Stripe's killer feature: Stripe Checkout.
If you've ever integrated local payment gateways (like ECPay), you'll know you need to build your own checkout page, package and encrypt the data, then redirect. Stripe Checkout completely revolutionizes this process.
Stripe Checkout is a Stripe-hosted payment page that has undergone A/B testing across billions of transactions, boasting extremely high conversion rates. It not only supports automatic language switching and currency localization but also comes with Apple Pay and Google Pay one-click payments built-in!
What we'll do now is use Next.js's backend API to "request" a dedicated Checkout URL from Stripe, then redirect users to it.
๐ป Implementation: Creating a Checkout Session API
Under the App Router architecture, we'll create an API route: src/app/api/stripe/checkout/route.ts.
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/utils/supabase/server'; // Assuming you use Supabase
// Initialize Stripe instance
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16', // Recommended to lock the API version
});
export async function POST(request: Request) {
try {
// 1. Authenticate user (ensure only logged-in users can purchase)
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Get the Price ID to purchase from the frontend
const body = await request.json();
const { priceId } = body;
// 3. Create Stripe Checkout Session
// This is the core API call!
const session = await stripe.checkout.sessions.create({
// Tell Stripe what items are included in this transaction
line_items: [
{
price: priceId, // Pass the Price ID we created in the Dashboard last chapter
quantity: 1, // Quantity is 1
},
],
// mode can be 'payment' (one-time) or 'subscription' (recurring)
mode: 'subscription',
// Success and cancel redirect URLs (frontend versions of ReturnURL)
// Note: Must use environment variables to dynamically determine URLs to avoid redirecting to localhost after deployment!
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
// Sneak our database's User ID into the metadata
// When payment succeeds, Stripe Webhook will pass this ID back to us
metadata: {
userId: user.id,
},
// Pre-fill the user's email to save them time and boost conversion rates!
customer_email: user.email,
});
// 4. Return the generated Checkout URL to the frontend
return NextResponse.json({ url: session.url });
} catch (error: any) {
console.error('Stripe Checkout Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
๐ก Code Highlights
mode: 'subscription': With just this line, Stripe automatically handles monthly recurring billing logicโno need to write your own Cron Jobs! If you're selling one-time purchases (like courses), simply change this tomode: 'payment'.metadata: { userId: user.id }: This is an extremely important technique! It's like theCustomFieldin ECPay. When a user completes payment on Stripe's page, Stripe sends a Webhook to us. We rely on thismetadata.userIdto know which account in our database to grant access to.success_url: This is the page users see after successful payment. Stripe allows you to include the{CHECKOUT_SESSION_ID}variable in the URL, which Stripe automatically replaces with the real Session ID, letting your frontend fetch data with it.
๐ฅ๏ธ Frontend Integration: Initiating Checkout
After setting up the backend API, the frontend button becomes very simple. On your Pricing page:
'use client';
import { useState } from 'react';
export default function PricingButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const data = await response.json();
if (data.url) {
// Directly redirect the browser to Stripe's high-conversion checkout page
window.location.href = data.url;
}
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="bg-indigo-600 text-white font-bold py-3 px-6 rounded-xl hover:bg-indigo-700 transition"
>
{loading ? 'Preparing checkout...' : 'Pay with Apple Pay / Credit Card'}
</button>
);
}
โ Chapter Summary
Now, when users click the button, they'll be seamlessly redirected to Stripe's official checkout page, where they can see Apple Pay or Google Pay options!
But waitโafter users complete payment and are redirected to the success_url, how do we grant them access?
In the next chapter, we'll explore the soul of payment systems: Webhook server-to-server communication. This ensures that even if the user's browser crashes during payment, your database will still accurately record this USD transaction!
Creating a Checkout Session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createCheckoutSession(priceId, customerEmail) {
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment',
customer_email: customerEmail,
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/cancel`,
metadata: {
order_id: 'ORDER-12345',
},
});
return session;
}
API Route Example
// pages/api/create-checkout-session.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { priceId, customerEmail } = req.body;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'payment',
customer_email: customerEmail,
success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/cancel`,
});
res.status(200).json({ url: session.url });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
Frontend Integration
// pages/checkout.js
import { useState } from 'react';
export default function CheckoutPage() {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId: 'price_1234567890',
customerEmail: 'customer@example.com',
}),
});
const data = await response.json();
if (data.url) {
// Redirect to Stripe Checkout
window.location.href = data.url;
} else {
console.error('Failed to create checkout session');
setLoading(false);
}
};
return (
<div>
<h1>Complete Your Purchase</h1>
<p>Camping Course Bundle โ $49.99</p>
<button onClick={handleCheckout} disabled={loading}>
{loading ? 'Redirecting...' : 'Pay Now'}
</button>
</div>
);
}
Success and Cancel Pages
// pages/success.js
export default function SuccessPage() {
return (
<div>
<h1>Payment Successful!</h1>
<p>Thank you for your purchase. You now have access to all courses.</p>
<a href="/dashboard">Go to Dashboard</a>
</div>
);
}
// pages/cancel.js
export default function CancelPage() {
return (
<div>
<h1>Payment Cancelled</h1>
<p>Your payment was not completed. You can try again anytime.</p>
<a href="/checkout">Try Again</a>
</div>
);
}
Common Issues
| Issue | Cause | Solution | |-------|-------|----------| | Invalid price ID | Price does not exist or is archived | Check Stripe Dashboard for price ID | | CORS error | Frontend URL not allowed | Ensure success_url uses the correct origin | | Session expired | User took too long to pay | Set a shorter timeout or handle expiration | | Duplicate charges | User clicked pay multiple times | Disable button after first click | | Currency mismatch | Price currency differs from account | Match price currency to Stripe account |
Summary
Stripe Checkout is the fastest way to accept payments. Create a Checkout Session server-side with the product price and customer details, then redirect the user to the Stripe-hosted payment page. Handle success and cancel URLs to provide feedback to the user.
Key takeaways:
- Checkout Session = hosted payment page, no PCI compliance needed
- Create session server-side with Stripe secret key
- Return session.url to frontend, redirect user there
- success_url: where user goes after successful payment
- cancel_url: where user goes if they cancel
- Use metadata to associate payments with orders
- Include {CHECKOUT_SESSION_ID} in success URL for reference
What's Next: Stripe Webhook Handling
The next chapter covers Stripe webhooks โ receiving async payment notifications, verifying signatures, and updating order status.