Right now the modal is rendering the placeholder (“Click below…”) because the Stripe Payment Element never mounts. That happens when clientSecret is null/undefined on the client. In other words: your POST /api/billing/create-subscription isn’t returning a usable clientSecret, so there’s nothing to render.

Here’s the fastest way to fix + prove it:

1) Verify the backend actually returns a client secret

Open DevTools → Network → click your Subscribe button → select the request to /api/billing/create-subscription. You should see a 200 JSON with both fields:

{ "clientSecret": "pi_..._secret_...", "subscriptionId": "sub_..." }


If clientSecret is missing/empty, fix your route exactly like this:

// src/server/routes/billing.ts
import type { Request, Response } from "express";
import express from "express";
import Stripe from "stripe";

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { apiVersion: "2024-06-20" });

router.post("/create-subscription", async (req: Request, res: Response) => {
  try {
    const { priceId, email } = req.body as { priceId: string; email?: string | null };
    if (!priceId) return res.status(400).json({ error: "Missing priceId" });

    // 1) Ensure this Price is RECURRING and ACTIVE in Stripe dashboard.
    // 2) Create or reuse a customer
    let customer: Stripe.Customer;
    if (email) {
      const found = await stripe.customers.list({ email, limit: 1 });
      customer = found.data[0] ?? await stripe.customers.create({ email });
    } else {
      customer = await stripe.customers.create();
    }

    // 3) Create subscription in incomplete state so we can collect payment
    const sub = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: priceId }],
      payment_behavior: "default_incomplete",
      expand: ["latest_invoice.payment_intent"], // REQUIRED to get PI
    });

    const invoice = sub.latest_invoice as Stripe.Invoice | null;
    const pi = invoice?.payment_intent as Stripe.PaymentIntent | null;

    if (!pi?.client_secret) {
      // Common causes: wrong price (one-time instead of recurring), legacy API version, or missing expand.
      return res.status(500).json({ error: "No client secret on payment intent." });
    }

    res.json({ clientSecret: pi.client_secret, subscriptionId: sub.id });
  } catch (e: any) {
    console.error("create-subscription error", e);
    res.status(500).json({ error: e.message || "Stripe error" });
  }
});

export default router;


Quick gotchas to check:

STRIPE_SECRET_KEY is set on the server.

The priceId is a recurring price (not one-time).

You didn’t set collection_method: "send_invoice" or anything that avoids creating a PaymentIntent.

API version must be recent (route above pins it).