Payment Intents (Custom Payment Flow)
Recipe
Create a PaymentIntent on the server to get a client secret, pass it to the Elements provider, render a PaymentElement for card input, and confirm the payment client-side.
Create the PaymentIntent server-side:
// app/actions/payment.ts
"use server";
import { stripe } from "@/lib/stripe";
export async function createPaymentIntent(amount: number) {
const paymentIntent = await stripe.paymentIntents.create({
amount, // in cents
currency: "usd",
automatic_payment_methods: { enabled: true },
});
return { clientSecret: paymentIntent.client_secret! };
}Wrap the checkout form with Elements using the client secret:
// app/checkout/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "./checkout-form";
export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState<string | null>(null);
useEffect(() => {
createPaymentIntent(2999).then(({ clientSecret }) => {
setClientSecret(clientSecret);
});
}, []);
if (!clientSecret) return <div>Loading...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}Build the checkout form:
// app/checkout/checkout-form.tsx
"use client";
import { useState, type FormEvent } from "react";
import {
useStripe,
useElements,
PaymentElement,
} from "@stripe/react-stripe-js";
export function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message ?? "Validation failed.");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/success`,
},
});
if (confirmError) {
setError(confirmError.message ?? "Payment failed.");
setProcessing(false);
}
// If successful, Stripe redirects to return_url
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-6">
<PaymentElement />
{error && <p className="text-red-500 mt-4">{error}</p>}
<button
type="submit"
disabled={!stripe || processing}
className="w-full mt-6 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{processing ? "Processing..." : "Pay $29.99"}
</button>
</form>
);
}Working Example
// app/donate/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "@/app/checkout/checkout-form";
const DONATION_AMOUNTS = [500, 1000, 2500, 5000];
export default function DonatePage() {
const [amount, setAmount] = useState(1000);
const [clientSecret, setClientSecret] = useState<string | null>(null);
useEffect(() => {
setClientSecret(null);
createPaymentIntent(amount).then(({ clientSecret }) => {
setClientSecret(clientSecret);
});
}, [amount]);
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Make a Donation</h1>
<div className="flex gap-2 mb-6">
{DONATION_AMOUNTS.map((amt) => (
<button
key={amt}
onClick={() => setAmount(amt)}
className={`px-4 py-2 rounded ${
amount === amt
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-800"
}`}
>
${(amt / 100).toFixed(0)}
</button>
))}
</div>
{clientSecret ? (
<Elements
stripe={stripePromise}
options={{ clientSecret }}
key={clientSecret}
>
<CheckoutForm />
</Elements>
) : (
<div>Loading payment form...</div>
)}
</div>
);
}Deep Dive
How It Works
- A PaymentIntent represents a single payment attempt on Stripe's servers. It tracks the lifecycle from creation through confirmation to completion.
- The
client_secretis a token that lets the client-side code confirm the payment without exposing your secret key. It should never be logged or stored. automatic_payment_methods: { enabled: true }tells Stripe to dynamically show the best payment methods for the customer's location and the transaction currency.elements.submit()validates all form fields before confirmation. Always call it beforeconfirmPaymentto catch validation errors early.stripe.confirmPaymentsends the payment details directly from the browser to Stripe. Card data never passes through your server.- After successful confirmation, Stripe redirects to
return_url. For 3D Secure, Stripe handles the authentication flow automatically.
Variations
Confirm without redirect (stay on page):
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (error) {
setError(error.message ?? "Payment failed.");
} else if (paymentIntent?.status === "succeeded") {
// Show success message without redirecting
setSuccess(true);
}Add metadata to the PaymentIntent:
const paymentIntent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: {
userId: user.id,
productId: product.id,
},
});TypeScript Notes
stripe.paymentIntents.createreturnsPromise<Stripe.PaymentIntent>.- The
confirmPaymentreturn type includes{ error?: StripeError; paymentIntent?: PaymentIntent }. - When using
redirect: "if_required", always check botherrorandpaymentIntentin the result.
import type { StripeError } from "@stripe/stripe-js";
function handleError(error: StripeError) {
switch (error.type) {
case "card_error":
return error.message;
case "validation_error":
return "Please check your card details.";
default:
return "An unexpected error occurred.";
}
}Gotchas
- Each PaymentIntent should be created once per checkout attempt. Do not create a new one every time the component re-renders -- use
useEffectwith stable dependencies. - The
client_secretcontains the PaymentIntent ID. Treat it as sensitive and do not expose it in URLs or logs. - When the amount changes, you need a new PaymentIntent. Use the
keyprop onElementsto force a full remount when theclientSecretchanges. confirmPaymentwithredirect: "always"(the default) will always redirect, even for card payments. Useredirect: "if_required"if you want to stay on the page.- The PaymentIntent amount is in the smallest currency unit. For USD, 2999 means $29.99. For JPY (zero-decimal currency), 2999 means 2999 yen.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| PaymentIntent + PaymentElement | Full UI control, supports all payment methods | More code than Checkout |
| Stripe Checkout | Minimal code, hosted UI | Limited customization |
| SetupIntent | Saves card without charging | Requires separate charge step later |
| PaymentIntent + CardElement | Fine-grained card field control | Only supports card payments |
FAQs
What is a PaymentIntent and what role does the client_secret play?
- A PaymentIntent represents a single payment attempt tracked on Stripe's servers
- The
client_secretis a token that lets client-side code confirm the payment without exposing your secret API key - It should never be logged, stored in a database, or exposed in URLs
Why should you call elements.submit() before stripe.confirmPayment()?
elements.submit() validates all form fields first. Calling it before confirmPayment catches validation errors (missing fields, invalid card format) early, providing a better user experience before attempting the actual charge.
What does automatic_payment_methods: { enabled: true } do?
It tells Stripe to dynamically select and display the best payment methods based on the customer's location, the transaction currency, and your Stripe Dashboard settings -- without you hardcoding specific methods.
Gotcha: What happens if you create a new PaymentIntent on every component re-render?
You end up with orphaned PaymentIntents on Stripe's servers and potentially confusing behavior. Always create the PaymentIntent in a useEffect with stable dependencies so it runs once per checkout attempt.
How do you stay on the page after payment instead of redirecting?
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (paymentIntent?.status === "succeeded") {
setSuccess(true);
}Use redirect: "if_required" -- it only redirects when the payment method requires it (e.g., 3D Secure).
Why is the key prop needed on the Elements component when the clientSecret changes?
Changing the clientSecret prop alone does not reset the internal Stripe Elements state. Using key={clientSecret} forces React to unmount and remount the <Elements> component, creating a fresh instance tied to the new PaymentIntent.
How do you attach metadata to a PaymentIntent?
const paymentIntent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: { userId: user.id, productId: product.id },
});Metadata is useful for correlating payments with your app's data in webhook handlers.
What is the difference between PaymentIntent amounts for USD vs JPY?
Amounts are in the smallest currency unit. For USD, 2999 = $29.99 (cents). For JPY (a zero-decimal currency), 2999 = 2999 yen. Always check whether the currency has decimal places.
TypeScript: How is the confirmPayment return type structured?
// Returns { error?: StripeError; paymentIntent?: PaymentIntent }
// When using redirect: "if_required", always check both fields
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});TypeScript: How do you handle different StripeError types with type narrowing?
import type { StripeError } from "@stripe/stripe-js";
function handleError(error: StripeError) {
switch (error.type) {
case "card_error":
return error.message;
case "validation_error":
return "Check your card details.";
default:
return "An unexpected error occurred.";
}
}Gotcha: What is the default redirect behavior of confirmPayment?
The default is redirect: "always", which redirects the user even for simple card payments that do not require 3D Secure. If you want to handle the result client-side, you must explicitly set redirect: "if_required".