Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Crea un PaymentIntent en el servidor para obtener un client secret, pásalo al proveedor Elements, renderiza un PaymentElement para la entrada de tarjeta y confirma el pago en el cliente.
Crea el PaymentIntent en el servidor:
// app/actions/payment.ts
"use server";
import { stripe } from "@/lib/stripe";
export async function createPaymentIntent(amount: number) {
const paymentIntent = await stripe.paymentIntents.create({
amount, // en centavos
currency: "usd",
automatic_payment_methods: { enabled: true },
});
return { clientSecret: paymentIntent.client_secret! };
}Envuelve el formulario de checkout con Elements usando el 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>Cargando...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}Construye el formulario de checkout:
// 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 ?? "Error de validación.");
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/success`,
},
});
if (confirmError) {
setError(confirmError.message ?? "Pago fallido.");
setProcessing(false);
}
// Si tiene éxito, Stripe redirige a 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 ? "Procesando..." : "Pagar $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">Haz una donación</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>Cargando formulario de pago...</div>
)}
</div>
);
}client_secret es un token que permite al código del cliente confirmar el pago sin exponer tu clave secreta. Nunca debe registrarse ni almacenarse.automatic_payment_methods: { enabled: true } indica a Stripe que muestre dinámicamente los mejores métodos de pago según la ubicación del cliente y la moneda de la transacción.elements.submit() valida todos los campos del formulario antes de la confirmación. Llámalo siempre antes de confirmPayment para detectar errores de validación de forma temprana.stripe.confirmPayment envía los datos de pago directamente desde el navegador a Stripe. Los datos de la tarjeta nunca pasan por tu servidor.return_url. Para 3D Secure, Stripe gestiona automáticamente el flujo de autenticación.Confirmar sin redirección (permanecer en la página):
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (error) {
setError(error.message ?? "Pago fallido.");
} else if (paymentIntent?.status === "succeeded") {
// Mostrar mensaje de éxito sin redirigir
setSuccess(true);
}Añadir metadata al 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 devuelve Promise<Stripe.PaymentIntent>.confirmPayment incluye { error?: StripeError; paymentIntent?: PaymentIntent }.redirect: "if_required", comprueba siempre tanto error como paymentIntent en el resultado.import type { StripeError } from "@stripe/stripe-js";
function handleError(error: StripeError) {
switch (error.type) {
case "card_error":
return error.message;
case "validation_error":
return "Revisa los datos de tu tarjeta.";
default:
return "Ocurrió un error inesperado.";
}
}useEffect con dependencias estables.client_secret contiene el ID del PaymentIntent. Trátalo como sensible y no lo expongas en URLs ni en logs.key en Elements para forzar un remontaje completo cuando cambie el clientSecret.confirmPayment con redirect: "always" (el valor por defecto) siempre redirige, incluso para pagos con tarjeta. Usa redirect: "if_required" si quieres permanecer en la página.| Enfoque | Ventajas | Desventajas |
|---|---|---|
| PaymentIntent + PaymentElement | Control total de la UI, admite todos los métodos de pago | Más código que Checkout |
| Stripe Checkout | Código mínimo, UI alojada | Personalización limitada |
| SetupIntent | Guarda la tarjeta sin cobrar | Requiere un paso de cobro separado más adelante |
| PaymentIntent + CardElement | Control granular a nivel de campo de tarjeta | Solo admite pagos con tarjeta |
client_secret es un token que permite al código del cliente confirmar el pago sin exponer tu clave API secretaelements.submit() valida primero todos los campos del formulario. Llamarlo antes de confirmPayment detecta errores de validación (campos faltantes, formato de tarjeta inválido) de forma temprana, lo que ofrece una mejor experiencia de usuario antes de intentar el cobro real.
Indica a Stripe que seleccione y muestre dinámicamente los mejores métodos de pago según la ubicación del cliente, la moneda de la transacción y la configuración de tu Stripe Dashboard, sin que tengas que codificar métodos específicos de forma rígida.
Terminas con PaymentIntents huérfanos en los servidores de Stripe y un comportamiento potencialmente confuso. Crea siempre el PaymentIntent en un useEffect con dependencias estables para que se ejecute una vez por intento de checkout.
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: "if_required",
});
if (paymentIntent?.status === "succeeded") {
setSuccess(true);
}Usa redirect: "if_required": solo redirige cuando el método de pago lo requiere (p. ej., 3D Secure).
Cambiar solo la prop clientSecret no restablece el estado interno de Stripe Elements. Usar key={clientSecret} obliga a React a desmontar y volver a montar el componente <Elements>, creando una instancia nueva vinculada al PaymentIntent nuevo.
const paymentIntent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: { userId: user.id, productId: product.id },
});La metadata es útil para correlacionar pagos con los datos de tu app en los manejadores de webhooks.
Los importes están en la unidad más pequeña de la moneda. Para USD, 2999 = $29.99 (centavos). Para JPY (una moneda sin decimales), 2999 = 2999 yenes. Comprueba siempre si la moneda tiene decimales.
// Devuelve { error?: StripeError; paymentIntent?: PaymentIntent }
// Cuando uses redirect: "if_required", comprueba siempre ambos campos
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 "Revisa los datos de tu tarjeta.";
default:
return "Ocurrió un error inesperado.";
}
}El valor por defecto es redirect: "always", que redirige al usuario incluso para pagos con tarjeta simples que no requieren 3D Secure. Si quieres gestionar el resultado en el cliente, debes establecer explícitamente redirect: "if_required".
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥