Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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>
);
}// 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>
);
}client_secret is 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 before confirmPayment to catch validation errors early.stripe.confirmPayment sends the payment details directly from the browser to Stripe. Card data never passes through your server.return_url. For 3D Secure, Stripe handles the authentication flow automatically.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,
},
});stripe.paymentIntents.create returns Promise<Stripe.PaymentIntent>.confirmPayment return type includes { error?: StripeError; paymentIntent?: PaymentIntent }.redirect: "if_required", always check both error and paymentIntent in 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.";
}
}useEffect with stable dependencies.client_secret contains the PaymentIntent ID. Treat it as sensitive and do not expose it in URLs or logs.key prop on Elements to force a full remount when the clientSecret changes.confirmPayment with redirect: "always" (the default) will always redirect, even for card payments. Use redirect: "if_required" if you want to stay on the page.| 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 |
client_secret is a token that lets client-side code confirm the payment without exposing your secret API keyelements.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.
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.
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.
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).
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.
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.
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.
// Returns { error?: StripeError; paymentIntent?: PaymentIntent }
// When using redirect: "if_required", always check both fields
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});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.";
}
}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".
Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥