<CheckoutForm>
Drop-in checkout form. Branches on whether the event is free or
paid from useTicketingConfig() (enabled === true && stripe === null
↔ free, stripe !== null ↔ paid), mounts Stripe Elements for paid
events, and runs the paid flow (checkout endpoint → capture
PaymentMethod → stripe.confirmPayment). Fires the provider's
paymentSucceeded or paymentFailed callback on terminal outcomes.
Import
import { CheckoutForm } from "@feelrift/react";
Basic usage
import { CheckoutForm, RiftEvent, RiftProvider } from "@feelrift/react";
<RiftProvider
on={{
paymentSucceeded: (orderId) => router.push(`/thanks?o=${orderId}`),
}}
>
<RiftEvent eventId="evt_summerfest_2026">
{/* … prior steps … */}
<CheckoutForm returnUrl="https://your.site/checkout/return" />
</RiftEvent>
</RiftProvider>;
Props
Accepts all HTMLAttributes<HTMLFormElement> plus:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
asChild | boolean | No | false | Swap the root element via Radix Slot. The form behavior (submit handler, disabled states) is preserved. |
returnUrl | string | ((response: CheckoutResponse) => string) | No (required at runtime for paid events) | — | URL Stripe redirects back to after confirmPayment. String for static returns; function for per-checkout URLs that encode order context. See returnUrl below. |
submitLabel | ReactNode | No | t("checkout.confirm") / t("checkout.confirmFree") | One-off label for the submit button. Defaults differ for free vs paid events. |
emailLabel | ReactNode | No | t("checkout.emailLabel") | Label for the email input. |
emptyFallback | ReactNode | No | null | Rendered when there's no active reservation. |
successFallback | ReactNode | No | Built-in success banner | Rendered after a free order completes or after a paid confirmPayment resolves OK. |
className | string | No | — | Appended to the SDK's rift-checkout-form (and --free / --paid variant) class. |
…rest | HTMLAttributes<HTMLFormElement> | No | — | Forwarded to the root element. |
returnUrl
Required for paid events — Stripe needs somewhere to send the user after a redirect-required confirmation (3DS / iDEAL / Apple Pay fallback). The component throws at render time if it's omitted on a paid event:
<CheckoutForm> requires a `returnUrl` for paid events. Pass the URL
Stripe should redirect to after `confirmPayment` (e.g.
https://your.site/checkout/return).
The URL is consumer-owned because the SDK doesn't know the host's routing. Accept either form:
Static string — fine when the return page can read the
persisted store (same origin, <RiftProvider> mounted on the return
route):
<CheckoutForm returnUrl={`https://${host}/checkout/return`} />
Function — called synchronously between the server checkout
response and stripe.confirmPayment. Receives the full
CheckoutResponse (including orderId and orderStatusToken) so
consumers can encode order context directly into the URL:
<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://your.site/checkout/return?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>
Use the function form when the post-redirect page can't reach the
persisted store — a different origin, an SSR-rendered confirmation
route, a return URL that opens in a new tab, or a setup where
<RiftProvider> isn't mounted on the return page.
Note Redirect-required payment methods (3DS, BLIK, iDEAL, wallet redirects) unload the consumer's app. The SDK can't fire
paymentSucceededacross the redirect — the URL is the only handoff. The function form is what makes that handoff stateless.
Warning The
orderStatusTokencarried in the redirect URL is a bearer credential — readable from browser history, theRefererheader on outbound clicks, and server access logs. The return page must strip it from the URL on first render to close those leaks. See Securing the order-status token for the recommended pattern and the alternatives considered.
emptyFallback / successFallback
Both control transient render states:
emptyFallback— rendered whenuseReservation().data === null(no active reservation). Defaultnullmakes the component disappear when there's nothing to check out.successFallback— rendered when the flow reaches its terminal success state (free orders OR paidconfirmPaymentresolved OK). Default is a<div class="rift-checkout-form__success">carryingt("checkout.confirmFree").
For full post-purchase UI, leave both as defaults and route the
user away in <RiftProvider on={{ paymentSucceeded }}> instead.
Render output
Two variants depending on useTicketingConfig().data:
- Free (
enabled === true && stripe === null): renders just the email field + submit button inside<form class="rift-checkout-form rift-checkout-form--free">. - Paid (
stripe !== null): mounts Stripe<Elements>with the reservation's amount/currency and the SDK'stokensToStripeAppearance(), then renders the email field, the Stripe<PaymentElement>, and the submit button inside<form class="rift-checkout-form rift-checkout-form--paid">.
While Stripe.js loads (between mount and stripePromise resolving)
the paid path renders a loading placeholder.
Behavior
- Free path flow.
- User submits →
useCheckout().prepare({ email }). - Server returns success directly (
payment: null). useCheckoutsetsstatus: "success"and fireson.paymentSucceeded(orderId, orderStatusToken).
- User submits →
- Paid path flow.
- User submits →
elements.submit()validates the<PaymentElement>locally. useCheckout().prepare({ email })mints the order + PaymentIntent client secret on the server.useCheckoutPayment().capture()stores the PaymentMethod.useCheckout().confirm({ returnUrl })charges the card.redirect: "if_required"keeps cards/wallets on-page; 3DS / iDEAL / Apple Pay redirect viareturnUrland resume there.- On success, the SDK fires
on.paymentSucceeded(orderId, orderStatusToken).
- User submits →
- Eager Stripe.js load.
<RiftEvent>triggersloadStripe()as soon as the Ticketing config arrives with a Stripe publishable key, so by the time the user reaches this form the script is already downloaded — no spinner gap. - Stripe deferred-flow contract. Elements mounts with
mode: "payment" + amount + currency + paymentMethodCreation: "manual". The client secret only travels tostripe.confirmPayment; it is never passed toelements.update()(not part of the deferred-flow contract). - No
clientSecretpersistence. Reloading mid-checkout re-mints the PaymentIntent from the persisted reservation token. Stale PaymentIntents would produce raw Stripe errors. - Submit disable conditions (paid path):
- Stripe or Elements not loaded.
- Email empty.
- A submit is in flight.
- Error surfaces. Stripe-side errors and server-side
RiftApiErrors render inline as.rift-checkout-form__error. For full UI control, omit the form and calluseCheckout()directly.
Errors
Errors surfaced inside the form, all routed through
useCheckout(). See the useCheckout errors table
for full per-code coverage; the highlights:
| Source | Code | When |
|---|---|---|
useCheckout | reservation_token_expired | Reservation TTL passed before submit. |
useCheckout | reservation_no_longer_valid | Previous attempt already completed. |
useCheckout | checkout_window_closed | Past the reservation's checkoutDeadline. |
useCheckout | validation_error | Email failed Zod validation server-side. |
| Stripe | card_declined, incorrect_cvc, etc. | Stripe-side payment failure; carries the StripeError shape. Fires on.paymentFailed. |
A <RiftProvider on={{ paymentFailed }}> callback receives both
shapes; check instanceof RiftApiError to discriminate.
Examples
Standard usage
<RiftProvider
on={{
paymentSucceeded: (orderId) => router.push(`/thanks?o=${orderId}`),
paymentFailed: (err) => Sentry.captureException(err),
}}
>
<RiftEvent eventId="evt_summerfest_2026">
{/* … prior steps … */}
<CheckoutForm returnUrl={`https://${host}/checkout/return`} />
</RiftEvent>
</RiftProvider>
Custom success state
<CheckoutForm
returnUrl={`https://${host}/checkout/return`}
successFallback={
<div className="success">
<h2>You're in.</h2>
<p>Check your inbox for your tickets.</p>
</div>
}
/>
Replace the email label
<CheckoutForm
returnUrl={`https://${host}/checkout/return`}
emailLabel="Send tickets to"
/>
Submit-label override for the free branch
<CheckoutForm submitLabel="Reserve my spot" />
returnUrl is optional. The component renders the free flow
without one. If the organizer reconfigures the event from free
to paid server-side, the component surfaces a structured error
prompting the consumer to supply a returnUrl — the form
doesn't silently mount payment Elements with a missing redirect
target.
Stateless return via URL-encoded order context
Encode the order's recovery handle into the redirect URL so the return page works without reading the persisted store:
<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://your.site/checkout/return?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>
On the return page, read the params and feed them straight into
useOrderStatus:
"use client";
import { useSearchParams } from "next/navigation";
import { useOrderStatus } from "@feelrift/react";
export function ReturnPage() {
const params = useSearchParams();
const orderId = params.get("o") ?? undefined;
const token = params.get("t") ?? undefined;
const { data, isTerminal } = useOrderStatus({ orderId, token });
if (!data) return <p>Loading…</p>;
return isTerminal ? <Receipt order={data} /> : <p>Finalizing…</p>;
}
No useRiftStore reach-in, no localStorage dependency, no
same-tab assumption.
Cross-domain confirmation page
The function form works across domains too — the SDK doesn't inspect or validate the URL:
<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://confirmation.bigfest.com/r?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>
The confirmation domain doesn't need to share storage or mount
<RiftProvider> — it reads the params and uses useOrderStatus
the same way the same-domain return page does.
Note The function is called synchronously and must return a string. Async URL building (server roundtrips, signed JWTs minted on demand) isn't supported by the prop directly — slow URL resolution would block the user-perceived redirect. For server-signed handoffs, sign on your backend's return endpoint after reading the URL-encoded
orderId, rather than at URL-build time.
Related
useCheckout— the underlying hook for custom forms.RiftProvideroncallbacks — wherepaymentSucceededandpaymentFailedare wired.RiftEvent— owns the Stripe.js loader.- Installation → Paid events — Stripe peer-dep requirements.