---
title: "Stripe Checkout Flow"
description: "Implement the Stripe Checkout flow for new subscriptions using a Server Action and redirect to Stripe's hosted checkout page."
canonical_url: "https://vercel.com/academy/subscription-store/stripe-checkout-flow"
md_url: "https://vercel.com/academy/subscription-store/stripe-checkout-flow.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T14:19:05.501Z"
content_type: "lesson"
course: "subscription-store"
course_title: "Launch a Subscription Store with Vercel and Stripe"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Stripe Checkout Flow

# 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:**
   ```bash
   pnpm dev
   ```

2. **Navigate to pricing:**
   - Sign in to your app
   - Go to <http://localhost:3000/protected/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

```bash
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:

```typescript title="utils/supabase/admin.ts" {58-89}
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`:

```typescript title="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`:

```typescript title="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 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 `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


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
