Stripe Checkout Flow
Stripe Checkout is a hosted payment page that handles card entry, validation, and 3D Secure authentication. Instead of building your own payment form, you redirect users to Stripe's secure page. You'll create a Server Action that generates checkout sessions and a client component that redirects to Stripe.
Outcome
Wire up the pricing cards to create checkout sessions and redirect users to Stripe Checkout for subscription purchases.
Fast Track
- Implement
checkoutWithStripe()Server Action inutils/stripe/server.ts - Implement
createOrRetrieveCustomer()inutils/supabase/admin.ts - Update
components/pricing-card.tsxto call checkout and redirect
Hands-on Exercise 2.3
Add checkout functionality to pricing cards:
Requirements:
- Implement
createOrRetrieveCustomer()to link Supabase users to Stripe customers - Implement
checkoutWithStripe()Server Action to create checkout sessions - Update
PricingCardto call the Server Action on button click - Redirect to Stripe Checkout URL on success
- Handle loading state during checkout creation
- Handle errors gracefully
Implementation hints:
- Use
"use server"directive for Server Actions - Get the authenticated user from Supabase before creating the session
- Stripe needs a customer ID - create one if the user doesn't have one
- Use
stripe.checkout.sessions.create()with mode"subscription" - Redirect with
stripe.redirectToCheckout({ sessionId })
Try It
-
Start dev server:
pnpm dev -
Navigate to pricing:
- Sign in to your app
- Go to http://localhost:3000/protected/pricing
-
Click "Select Plan":
- Click on any plan's "Select Plan" button
- You should be redirected to Stripe Checkout
-
Complete test purchase:
- Use Stripe test card:
4242 4242 4242 4242 - Any future expiry date (e.g., 12/34)
- Any CVC (e.g., 123)
- Click "Subscribe"
- Use Stripe test card:
-
Verify redirect:
- After payment, you should land on
/protected/subscription - The subscription page may show your new subscription
- After payment, you should land on
Commit
git add -A
git commit -m "feat(billing): add Stripe checkout flow"Done-When
createOrRetrieveCustomer()creates or retrieves Stripe customerscheckoutWithStripe()Server Action creates checkout sessions- "Select Plan" button triggers checkout
- Loading state shows while creating session
- User redirects to Stripe Checkout page
- Test card works for subscription
- After payment, user returns to app
Solution
Step 1: Implement Customer Management
Update utils/supabase/admin.ts to implement customer creation:
import { createClient } from "@supabase/supabase-js";
import { stripe } from "@/utils/stripe/config";
import Stripe from "stripe";
// Admin client with service role key
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// Create a customer in Stripe
const createCustomerInStripe = async (uuid: string, email: string) => {
const customerData = { metadata: { supabaseUUID: uuid }, email };
const newCustomer = await stripe.customers.create(customerData);
if (!newCustomer) throw new Error("Stripe customer creation failed.");
return newCustomer.id;
};
// Upsert customer to Supabase
const upsertCustomerToSupabase = async (
uuid: string,
stripeCustomerId: string
) => {
const { error } = await supabaseAdmin
.from("customers")
.upsert([{ id: uuid, stripe_customer_id: stripeCustomerId }]);
if (error)
throw new Error(`Supabase customer insert/update failed: ${error.message}`);
return stripeCustomerId;
};
// Create or retrieve a Stripe customer
export const createOrRetrieveCustomer = async ({
email,
uuid,
}: {
email: string;
uuid: string;
}) => {
// Check if customer exists in Supabase
const { data: existingSupabaseCustomer, error: queryError } =
await supabaseAdmin
.from("customers")
.select("*")
.eq("id", uuid)
.maybeSingle();
if (queryError) {
throw new Error(`Supabase customer lookup failed: ${queryError.message}`);
}
// Retrieve Stripe customer ID or check by email
let stripeCustomerId: string | undefined;
if (existingSupabaseCustomer?.stripe_customer_id) {
const existingStripeCustomer = await stripe.customers.retrieve(
existingSupabaseCustomer.stripe_customer_id
);
stripeCustomerId = existingStripeCustomer.id;
} else {
// Check if customer exists in Stripe by email
const stripeCustomers = await stripe.customers.list({ email });
stripeCustomerId =
stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
}
// Create customer if needed
const stripeIdToInsert = stripeCustomerId
? stripeCustomerId
: await createCustomerInStripe(uuid, email);
// Sync to Supabase if needed
if (existingSupabaseCustomer && stripeCustomerId) {
if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) {
await supabaseAdmin
.from("customers")
.update({ stripe_customer_id: stripeCustomerId })
.eq("id", uuid);
}
return stripeCustomerId;
} else {
await upsertCustomerToSupabase(uuid, stripeIdToInsert);
return stripeIdToInsert;
}
};This function:
- Checks if the user already has a Stripe customer ID in Supabase
- If not, checks if a customer with their email exists in Stripe
- If still not found, creates a new customer in Stripe
- Syncs the customer ID back to Supabase
Step 2: Implement Checkout Server Action
Update utils/stripe/server.ts:
"use server";
import { stripe } from "./config";
import { createSupabaseClient } from "@/utils/supabase/server";
import { createOrRetrieveCustomer } from "@/utils/supabase/admin";
function getURL(path: string = "") {
let url =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.VERCEL_URL ??
"http://localhost:3000";
// Make sure to include https:// when not localhost
url = url.startsWith("http") ? url : `https://${url}`;
// Remove trailing slash
url = url.endsWith("/") ? url.slice(0, -1) : url;
return path ? `${url}${path}` : url;
}
export type CheckoutResponse = {
sessionId?: string;
errorRedirect?: string;
};
export async function checkoutWithStripe(
priceId: string,
redirectPath: string = "/protected/subscription"
): Promise<CheckoutResponse> {
try {
const supabase = await createSupabaseClient();
const {
error,
data: { user },
} = await supabase.auth.getUser();
if (error || !user) {
throw new Error("Could not get user session.");
}
// Get or create Stripe customer
let customer: string;
try {
customer = await createOrRetrieveCustomer({
uuid: user.id,
email: user.email || "",
});
} catch {
throw new Error("Unable to access customer record.");
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
billing_address_collection: "required",
customer,
customer_update: {
address: "auto",
},
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: true,
success_url: getURL(redirectPath),
cancel_url: getURL("/protected/pricing"),
});
if (session) {
return { sessionId: session.id };
} else {
throw new Error("Unable to create checkout session.");
}
} catch (error) {
if (error instanceof Error) {
return {
errorRedirect: `/protected/pricing?error=${encodeURIComponent(error.message)}`,
};
}
return {
errorRedirect: `/protected/pricing?error=Unknown error occurred`,
};
}
}Key aspects:
"use server"- Marks this as a Server Action callable from the clientgetURL()- Builds absolute URLs for redirects that work in any environment- Error handling - Returns
errorRedirectinstead of throwing, so the client can navigate
Step 3: Update Pricing Card
Update components/pricing-card.tsx:
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ProductWithPrices } from "@/utils/supabase/queries";
import { checkoutWithStripe } from "@/utils/stripe/server";
import { getStripe } from "@/utils/stripe/client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface PricingCardProps {
product: ProductWithPrices;
isCurrentPlan: boolean;
}
export default function PricingCard({ product, isCurrentPlan }: PricingCardProps) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
function getCurrencySymbol(currency: string) {
switch (currency?.toLowerCase()) {
case "usd":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
case "cad":
case "aud":
return "$";
default:
return currency?.toUpperCase() || "$";
}
}
async function handleSelectPlan(priceId: string) {
setIsLoading(true);
try {
const { sessionId, errorRedirect } = await checkoutWithStripe(priceId);
if (errorRedirect) {
router.push(errorRedirect);
return;
}
if (sessionId) {
const stripe = await getStripe();
await stripe?.redirectToCheckout({ sessionId });
}
} catch (error) {
console.error("Checkout error:", error);
} finally {
setIsLoading(false);
}
}
// Get the monthly price (or first available price)
const price = product.prices?.find((p) => p.interval === "month") ||
product.prices?.[0];
if (!price) {
return null;
}
const { name, description } = product;
const symbol = getCurrencySymbol(price.currency);
const priceString = price.unit_amount
? `${symbol}${(price.unit_amount / 100).toFixed(2)}`
: "Custom";
return (
<Card className="p-6 space-y-4">
<div className="space-y-2">
<h3 className="text-xl font-medium">{name}</h3>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold">{priceString}</span>
{price.interval && (
<span className="text-muted-foreground">/{price.interval}</span>
)}
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<Button
className="w-full"
onClick={() => handleSelectPlan(price.id)}
disabled={isLoading || isCurrentPlan}
variant={isCurrentPlan ? "secondary" : "default"}
>
{isLoading
? "Loading..."
: isCurrentPlan
? "Current Plan"
: "Select Plan"}
</Button>
</Card>
);
}The flow:
- User clicks "Select Plan"
handleSelectPlancalls the Server Action- Server Action creates a Stripe checkout session
- Client loads Stripe.js and redirects to checkout
How Checkout Works
User clicks "Select Plan"
↓
handleSelectPlan(priceId)
↓
checkoutWithStripe() Server Action
↓
createOrRetrieveCustomer() → Stripe API
↓
stripe.checkout.sessions.create()
↓
Returns { sessionId }
↓
getStripe() → Load Stripe.js
↓
stripe.redirectToCheckout({ sessionId })
↓
Stripe Checkout Page
↓
User enters payment details
↓
Stripe processes payment
↓
Webhook: checkout.session.completed
↓
manageSubscriptionStatusChange()
↓
Subscription saved to Supabase
↓
Redirect to success_url (/protected/subscription)
Stripe Test Cards
Use these test cards during development:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 3220 | 3D Secure required |
4000 0000 0000 9995 | Declined (insufficient funds) |
Always use:
- Any future expiry date (e.g.,
12/34) - Any 3-digit CVC (e.g.,
123) - Any billing postal code (e.g.,
12345)
File Structure After This Lesson
utils/
├── stripe/
│ ├── config.ts ← Server Stripe client
│ ├── client.ts ← Browser Stripe loader
│ └── server.ts ← Updated: checkout Server Action
└── supabase/
├── admin.ts ← Updated: customer management
└── ...
components/
└── pricing-card.tsx ← Updated: checkout flow
Troubleshooting
Button doesn't respond to clicks:
- Check browser console for JavaScript errors
- Verify
isLoadingandisCurrentPlanaren't blocking the click - Ensure Server Action is properly exported
"Could not get user session" error:
- User must be signed in before checkout
- Verify the Supabase session is valid
- Check cookies are being sent with the request
Redirect goes to wrong URL:
- Verify
NEXT_PUBLIC_SITE_URLorVERCEL_URLis set correctly - Check that the redirect path exists in your app
Stripe page shows "Invalid session":
- The checkout session may have expired (they last 24 hours)
- Try creating a new checkout session
- Verify the price ID is valid in Stripe
Subscription doesn't appear after checkout:
- Check that webhooks are configured and running
- Verify the webhook handler is processing
checkout.session.completed - Check Supabase for the subscription record
Was this helpful?