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 sends the browser to the session's URL.
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" - Return
session.urlfrom the Server Action and send the browser there withwindow.location.assign(url)
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
utils/supabase/admin.ts already holds the supabaseAdmin client and the webhook sync helpers that your route from 2.1 imports. Leave those as they are. Replace the createOrRetrieveCustomer stub (its TODO comments and the function that throws) with these three functions:
// 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_PROJECT_PRODUCTION_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 = {
url?: 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({
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.url) {
return { url: session.url };
} 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 - No
payment_method_types- Checkout shows the payment methods you turn on in the Stripe Dashboard. To limit them in code, passallowed_payment_method_typesorexcluded_payment_method_types session.url- The hosted Checkout page for this session. The client sends the browser there
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 { 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 { url, errorRedirect } = await checkoutWithStripe(priceId);
if (errorRedirect) {
router.push(errorRedirect);
return;
}
if (url) {
window.location.assign(url);
}
} 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 sends the browser to the Checkout URL
How Checkout Works
User clicks "Select Plan"
↓
handleSelectPlan(priceId)
↓
checkoutWithStripe() Server Action
↓
createOrRetrieveCustomer() → Stripe API
↓
stripe.checkout.sessions.create()
↓
Returns { url: session.url }
↓
window.location.assign(url)
↓
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_URLis set correctly. Without it, the app falls back toVERCEL_PROJECT_PRODUCTION_URL, your production domain. It skipsVERCEL_URLon purpose: that per-deployment URL sits behind Standard Deployment Protection, so Stripe would send customers to a Vercel login page - Check that the redirect path exists in your app
Property 'redirectToCheckout' does not exist on type 'Stripe':
- Stripe.js no longer has
redirectToCheckout, so code copied from older tutorials fails here (at runtime it shows up asstripe.redirectToCheckout is not a function) - Return
session.urlfromcheckoutWithStripe()and callwindow.location.assign(url)instead
'payment_method_types' does not exist in type 'SessionCreateParams':
- Checkout Sessions no longer accept
payment_method_types - Delete the line. Checkout uses the payment methods enabled in your Dashboard
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?