Vercel Logo

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

  1. Implement checkoutWithStripe() Server Action in utils/stripe/server.ts
  2. Implement createOrRetrieveCustomer() in utils/supabase/admin.ts
  3. Update components/pricing-card.tsx to call checkout and redirect

Hands-on Exercise 2.3

Add checkout functionality to pricing cards:

Requirements:

  1. Implement createOrRetrieveCustomer() to link Supabase users to Stripe customers
  2. Implement checkoutWithStripe() Server Action to create checkout sessions
  3. Update PricingCard to call the Server Action on button click
  4. Redirect to Stripe Checkout URL on success
  5. Handle loading state during checkout creation
  6. 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

  1. Start dev server:

    pnpm dev
  2. Navigate to pricing:

  3. Click "Select Plan":

    • Click on any plan's "Select Plan" button
    • You should be redirected to Stripe Checkout
  4. 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"
  5. Verify redirect:

    • After payment, you should land on /protected/subscription
    • The subscription page may show your new subscription

Commit

git add -A
git commit -m "feat(billing): add Stripe checkout flow"

Done-When

  • createOrRetrieveCustomer() creates or retrieves Stripe customers
  • checkoutWithStripe() 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:

utils/supabase/admin.ts
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:

  1. Checks if the user already has a Stripe customer ID in Supabase
  2. If not, checks if a customer with their email exists in Stripe
  3. If still not found, creates a new customer in Stripe
  4. Syncs the customer ID back to Supabase

Step 2: Implement Checkout Server Action

Update utils/stripe/server.ts:

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 client
  • getURL() - Builds absolute URLs for redirects that work in any environment
  • Error handling - Returns errorRedirect instead of throwing, so the client can navigate

Step 3: Update Pricing Card

Update components/pricing-card.tsx:

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:

  1. User clicks "Select Plan"
  2. handleSelectPlan calls the Server Action
  3. Server Action creates a Stripe checkout session
  4. 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 NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 32203D Secure required
4000 0000 0000 9995Declined (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 isLoading and isCurrentPlan aren'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_URL or VERCEL_URL is 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