Skip to main content
The plan selection page displays all active pricing plans fetched live from Stripe. Users pick a plan and click Subscribe, which creates an incomplete Stripe subscription and sends them to the payment page to enter their card details.
This page requires an active session with a customerId. Users who navigate directly to /prices without completing registration will not have a customer ID in their session and the subscription action will fail.

How plans are loaded

Plan data is fetched on every page load directly from the Stripe API — there is no local cache or database. This means any changes you make to your products and prices in the Stripe Dashboard are reflected immediately.
app/services/payment.ts
export const getConfig = async () => {
  const prices = await stripe.prices.list({ expand: ["data.product"] });
  return { prices };
};
The expand: ["data.product"] option tells Stripe to inline the full product object on each price, so the UI can display the product name without a second API call.

Plan card contents

Each plan is displayed as a card showing:

Product name

The name of the Stripe product associated with the price (e.g. “Pro Plan”, “Starter”).

Monthly price

The unit amount from the Stripe price object, formatted as a currency value per month.

Subscribe button

A form button that POSTs the selected priceId to the page action.

Subscription initiation flow

1

User clicks Subscribe

Each plan card contains a form with a hidden priceId input. Clicking Subscribe submits that form via POST.
2

Server retrieves session and creates subscription

The route action reads the customerId from the session cookie and the priceId from the form body, then calls stripe.subscriptions.create.
app/routes/prices.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const session = await getSession(request.headers.get("Cookie"));
  const data = await request.formData();
  const priceId = data.get("priceId") as string;
  const customerId = session.get("customerId") as string;
  const { clientSecret } = await subscribe(customerId, priceId);
  return redirect(`/subscribe?client_secret=${clientSecret}`, {
    headers: { "Set-Cookie": await commitSession(session) },
  });
};
3

Stripe creates an incomplete subscription

The subscription is created with payment_behavior: "default_incomplete". This means Stripe holds the subscription in an incomplete state until payment is confirmed — the customer is not charged yet.
app/services/payment.ts
export const subscribe = async (customerId: string, priceId: string) => {
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: "default_incomplete",
    expand: ["latest_invoice.payment_intent"],
  });
  return {
    subscription,
    clientSecret: subscription.latest_invoice.payment_intent.client_secret,
  };
};
The expand: ["latest_invoice.payment_intent"] option retrieves the PaymentIntent client secret in the same call, which is required by Stripe Elements on the payment page.
4

User is redirected to payment

The clientSecret is passed as a query parameter to /subscribe. The payment page uses it to initialize Stripe Elements and confirm the card payment.

Stripe objects created

ObjectStateNotes
SubscriptionincompleteBecomes active after payment is confirmed
InvoiceopenThe first invoice for the subscription
PaymentIntentrequires_payment_methodConfirmed on the payment page
Because payment_behavior is set to default_incomplete, the subscription will remain in incomplete state indefinitely until the user completes payment. Stripe will eventually cancel incomplete subscriptions that are never paid (after 23 hours by default).

Next step

After a plan is selected, users are sent to the payment page to enter their card details and confirm the subscription.

Build docs developers (and LLMs) love