Think 2 small server endpoints + one React form. Here’s the clean, Stripe-recommended way to do subscriptions inside your page using Stripe Elements (Payment Element).

What we’ll build

Render card/Wallets inside your page (no redirect).

Create a subscription to your Price (monthly/yearly).

Confirm payment in place, then mark user Pro (webhook).

1) Server: create subscription (default_incomplete)
// routes/stripe-elements.ts
import express from 'express';
import Stripe from 'stripe';
const router = express.Router();

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { apiVersion: '2023-10-16' });

async function getOrCreateCustomer(uid: string, email: string) {
  // lookup by metadata or email — pick what you already store
  const list = await stripe.customers.list({ email, limit: 1 });
  return list.data[0] ?? await stripe.customers.create({ email, metadata: { uid } });
}

router.post('/api/stripe/elements/create-subscription', async (req: any, res) => {
  try {
    const { uid, email } = req.user; // from your auth middleware
    const { priceId, trialDays } = req.body;

    if (!priceId) return res.status(400).json({ error: 'Missing priceId' });

    const customer = await getOrCreateCustomer(uid, email);

    // Create subscription in "incomplete" state; Stripe generates a PaymentIntent for the first invoice
    const sub = await stripe.subscriptions.create({
      customer: customer.id,
      items: [{ price: priceId }],
      trial_period_days: trialDays || undefined,
      payment_behavior: 'default_incomplete',
      expand: ['latest_invoice.payment_intent'],
      metadata: { uid },
    });

    const pi = (sub.latest_invoice as any).payment_intent as Stripe.PaymentIntent;
    res.json({ subscriptionId: sub.id, clientSecret: pi.client_secret });
  } catch (e: any) {
    console.error('create-subscription error', e);
    res.status(400).json({ error: e?.message || 'Subscription error' });
  }
});

// Optional: manage/portal link
router.post('/api/stripe/customer-portal', async (req: any, res) => {
  const { uid, email } = req.user;
  const customer = await getOrCreateCustomer(uid, email);
  const session = await stripe.billingPortal.sessions.create({
    customer: customer.id,
    return_url: `${process.env.PUBLIC_URL}/account`,
  });
  res.json({ url: session.url });
});

export default router;


Keep your webhook from earlier to flip the user to pro on invoice.paid / checkout.session.completed / customer.subscription.created as needed.

2) Client: render Payment Element and confirm

Install if needed:

npm i @stripe/stripe-js @stripe/react-stripe-js


Payment form (opens inside your page):

// components/SubscribeElements.tsx
import { useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { Button } from '@/components/ui/button';
import { apiRequest } from '@/lib/queryClient';

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);

function PaymentForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;
    setSubmitting(true);
    setError(null);

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        // Optional: return_url for redirect-based wallets; not used for cards
        return_url: `${window.location.origin}/billing/success`,
      },
      redirect: 'if_required',
    });

    setSubmitting(false);

    if (error) {
      setError(error.message || 'Payment failed');
    } else {
      // Payment succeeded or no redirect required.
      // Your webhook will flip the user to pro; optionally poll user profile here.
      window.location.href = '/billing/success';
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <PaymentElement options={{ layout: 'tabs' }} />
      {error && <p className="text-red-600 text-sm">{error}</p>}
      <Button disabled={!stripe || submitting} type="submit">
        {submitting ? 'Processing…' : 'Start Trial'}
      </Button>
    </form>
  );
}

export default function SubscribeElements({ priceId, trialDays }: { priceId: string; trialDays?: number }) {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    (async () => {
      try {
        const res = await apiRequest('POST', '/api/stripe/elements/create-subscription', { priceId, trialDays });
        const txt = await res.text();
        const isJson = (res.headers.get('content-type') || '').includes('application/json');
        if (!res.ok) throw new Error(isJson ? (JSON.parse(txt).error || 'Create subscription failed') : `HTTP ${res.status}`);
        const data = JSON.parse(txt);
        setClientSecret(data.clientSecret);
      } catch (e: any) {
        setErr(e.message || 'Failed to start subscription');
      }
    })();
  }, [priceId, trialDays]);

  if (err) return <p className="text-red-600">{err}</p>;
  if (!clientSecret) return <p>Preparing secure payment…</p>;

  return (
    <Elements stripe={stripePromise} options={{ clientSecret, appearance: { theme: 'stripe' } }}>
      <PaymentForm clientSecret={clientSecret} />
    </Elements>
  );
}


Use it on your pricing page:

// Monthly
<SubscribeElements priceId={import.meta.env.VITE_SUBS_PRICE_ID_MONTHLY} trialDays={7} />

// Yearly
<SubscribeElements priceId={import.meta.env.VITE_SUBS_PRICE_ID_YEARLY} />

3) Webhook (no change, just ensure it’s on)

On invoice.paid (or customer.subscription.created when trial starts), set user role='pro'.

On customer.subscription.deleted, downgrade user.

You already have the pattern; keep using it.

4) Notes & tips

Security: Elements keeps card data in Stripe’s iframe fields → PCI-safe.

Wallets: Payment Element automatically enables Apple Pay/Google Pay when eligible.

Trials: Pass trialDays to the server; Stripe handles trial start and invoices later.

Errors: If confirmation requires 3DS, confirmPayment will handle the modal inline.