Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Use Server Actions do Next.js para lidar com todas as operações do Stripe no lado do servidor. Combine com useActionState para fluxos de pagamento baseados em formulário e Zod para validação de entrada.
Crie uma Sessão de Checkout via Server Action:
// app/actions/checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { z } from "zod";
const CheckoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.coerce.number().int().min(1).max(99),
});
export async function createCheckoutAction(
_prevState: { error?: string } | null,
formData: FormData
) {
const parsed = CheckoutSchema.safeParse({
priceId: formData.get("priceId"),
quantity: formData.get("quantity"),
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Entrada inválida" };
}
const { priceId, quantity } = parsed.data;
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/shop`,
});
redirect(session.url!);
} catch (err) {
// Re-lance erros de redirecionamento (Next.js os usa internamente)
if (err instanceof Error && err.message === "NEXT_REDIRECT") throw err;
return { error: "Falha ao criar sessão de checkout. Por favor, tente novamente." };
}
}Crie um PaymentIntent via Server Action:
// app/actions/payment-intent.ts
"use server";
import { stripe } from "@/lib/stripe";
import { z } from "zod";
const PaymentSchema = z.object({
amount: z.coerce.number().int().min(50).max(99999999), // Mínimo do Stripe é 50 centavos
currency: z.enum(["usd", "eur", "gbp"]).default("usd"),
});
export async function createPaymentIntentAction(
_prevState: { clientSecret?: string; error?: string } | null,
formData: FormData
) {
const parsed = PaymentSchema.safeParse({
amount: formData.get("amount"),
currency: formData.get("currency"),
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Entrada inválida" };
}
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: parsed.data.amount,
currency: parsed.data.currency,
automatic_payment_methods: { enabled: true },
});
return { clientSecret: paymentIntent.client_secret! };
} catch {
return { error: "Falha ao inicializar pagamento." };
}
}Gerencie assinaturas via Server Action:
// app/actions/subscription.ts
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function cancelSubscriptionAction() {
const session = await auth();
if (!session?.user?.id) return { error: "Não autenticado" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "Nenhuma assinatura ativa" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "Falha ao cancelar assinatura" };
}
}
export async function resumeSubscriptionAction() {
const session = await auth();
if (!session?.user?.id) return { error: "Não autenticado" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "Nenhuma assinatura encontrada" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: false,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "Falha ao retomar assinatura" };
}
}Fluxo completo de checkout usando Server Actions e useActionState:
// app/shop/product-card.tsx
"use client";
import { useActionState } from "react";
import { createCheckoutAction } from "@/app/actions/checkout";
interface ProductCardProps {
name: string;
description: string;
priceId: string;
priceDisplay: string;
}
export function ProductCard({
name,
description,
priceId,
priceDisplay,
}: ProductCardProps) {
const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);
return (
<div className="border rounded-xl p-6 space-y-4">
<h3 className="text-lg font-bold">{name}</h3>
<p className="text-gray-600">{description}</p>
<p className="text-2xl font-bold">{priceDisplay}</p>
<form action={formAction}>
<input type="hidden" name="priceId" value={priceId} />
<input type="hidden" name="quantity" value="1" />
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-3 rounded-lg
hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? "Redirecionando para o checkout..." : "Comprar Agora"}
</button>
</form>
{state?.error && (
<p className="text-red-500 text-sm">{state.error}</p>
)}
</div>
);
}// app/shop/page.tsx
import { ProductCard } from "./product-card";
const products = [
{
name: "Kit Inicial",
description: "Tudo o que você precisa para começar.",
priceId: "price_starter",
priceDisplay: "$19",
},
{
name: "Pacote Pro",
description: "Ferramentas avançadas para profissionais.",
priceId: "price_pro",
priceDisplay: "$49",
},
{
name: "Suíte Corporativa",
description: "Solução corporativa completa.",
priceId: "price_enterprise",
priceDisplay: "$199",
},
];
export default function ShopPage() {
return (
<div className="max-w-4xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold mb-8">Loja</h1>
<div className="grid md:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.priceId} {...product} />
))}
</div>
</div>
);
}Gerenciamento de assinatura com useActionState:
// components/subscription-controls.tsx
"use client";
import { useActionState } from "react";
import {
cancelSubscriptionAction,
resumeSubscriptionAction,
} from "@/app/actions/subscription";
interface SubscriptionControlsProps {
cancelAtPeriodEnd: boolean;
currentPeriodEnd: string;
}
export function SubscriptionControls({
cancelAtPeriodEnd,
currentPeriodEnd,
}: SubscriptionControlsProps) {
const [cancelState, cancelAction, isCanceling] = useActionState(
cancelSubscriptionAction,
null
);
const [resumeState, resumeAction, isResuming] = useActionState(
resumeSubscriptionAction,
null
);
if (cancelAtPeriodEnd) {
return (
<div className="space-y-4">
<p className="text-yellow-700 bg-yellow-50 p-4 rounded-lg">
Sua assinatura está programada para cancelar em{" "}
{new Date(currentPeriodEnd).toLocaleDateString()}.
</p>
<form action={resumeAction}>
<button
type="submit"
disabled={isResuming}
className="bg-green-600 text-white px-4 py-2 rounded-lg"
>
{isResuming ? "Retomando..." : "Retomar Assinatura"}
</button>
</form>
{resumeState?.error && (
<p className="text-red-500 text-sm">{resumeState.error}</p>
)}
</div>
);
}
return (
<div className="space-y-4">
<form action={cancelAction}>
<button
type="submit"
disabled={isCanceling}
className="bg-red-600 text-white px-4 py-2 rounded-lg"
>
{isCanceling ? "Cancelando..." : "Cancelar Assinatura"}
</button>
</form>
{cancelState?.error && (
<p className="text-red-500 text-sm">{cancelState.error}</p>
)}
</div>
);
}useActionState (React 19) gerencia o ciclo de vida do formulário: ele rastreia o estado pendente, passa o resultado anterior para a ação e re-renderiza quando a ação é concluída.(prevState, formData) => Promise<State> recebe o valor de retorno anterior e os dados do formulário enviados. Isso permite a exibição progressiva de erros sem gerenciamento de estado do lado do cliente.redirect() em uma Server Action lança um erro interno especial. Se você envolver a chamada do Stripe em try/catch, você deve relançar os erros de redirecionamento ou o redirecionamento será silenciosamente ignorado.revalidatePath aciona a re-busca de componentes do servidor no caminho especificado, mantendo a UI sincronizada após as mutações.Server Action com contexto de usuário autenticado:
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function subscribeAction(priceId: string) {
const session = await auth();
if (!session?.user) redirect("/login");
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: session.user.email!,
line_items: [{ price: priceId, quantity: 1 }],
metadata: { userId: session.user.id },
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
redirect(checkoutSession.url!);
}Combinando Server Action com Stripe.js do lado do cliente:
"use client";
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntentAction } from "@/app/actions/payment-intent";
import { CheckoutForm } from "./checkout-form";
export function PaymentWrapper({ amount }: { amount: number }) {
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const formData = new FormData();
formData.set("amount", String(amount));
formData.set("currency", "usd");
createPaymentIntentAction(null, formData).then((result) => {
if (result?.clientSecret) {
setClientSecret(result.clientSecret);
} else if (result?.error) {
setError(result.error);
}
});
}, [amount]);
if (error) return <p className="text-red-500">{error}</p>;
if (!clientSecret) return <div>Inicializando pagamento...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm amount={amount} />
</Elements>
);
}useActionState é genérico: useActionState<State>(action, initialState). O tipo do estado é inferido do tipo de retorno da ação.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}redirect() lança um erro interno NEXT_REDIRECT. Se sua Server Action tiver um try/catch em torno da chamada do Stripe e do redirecionamento, você deve relançar os erros que são erros de redirecionamento. A abordagem mais simples é chamar redirect fora do bloco try/catch.useActionState substitui o antigo useFormState de react-dom. Use useActionState de react no React 19.isPending de useActionState é true do momento em que o formulário é enviado até que a ação seja concluída (incluindo qualquer redirecionamento). Use-o para desabilitar botões e mostrar estados de carregamento.useEffect, manipuladores de eventos ou outro código do cliente, passando FormData manualmente.| Abordagem | Prós | Contras |
|---|---|---|
| Server Actions + useActionState | Melhoria progressiva, estado pendente integrado | Requer React 19 |
| Route Handlers (POST) | Funciona com qualquer cliente, API estilo REST | Mais código repetitivo, sem integração de formulário integrada |
| mutações tRPC | Type-safe de ponta a ponta, ótima DX | Dependência adicional |
| Server Actions sem useActionState | Mais simples para fluxos apenas de redirecionamento | Sem estado de erro ou pendente integrado |
useActionState para estados pendentes integrados.const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);state contém o resultado anterior da ação (mensagens de erro ou dados de sucesso).isPending é true desde o envio do formulário até a conclusão da ação.(prevState, formData) e retorna o novo estado.redirect() lança internamente um erro especial NEXT_REDIRECT.try/catch o engolir, o redirecionamento é perdido silenciosamente e o usuário permanece na página.redirect fora do bloco try/catch.useActionState é importado de react (React 19) e substitui useFormState de react-dom.isPending que useFormState não fornecia.useFormState no React 19 será depreciado.cancel_at_period_end: true permite que o usuário mantenha o acesso até o final do seu período de faturamento atual.false antes do final do período.revalidatePath("/account") atualiza a UI para refletir a mudança.useEffect, manipuladores de eventos ou outro código do cliente.FormData manualmente ao chamar fora de um contexto de formulário.createPaymentIntentAction para obter um clientSecret do servidor.clientSecret para o provedor Elements como uma opção.CheckoutForm do lado do cliente usa então o Stripe.js para confirmar o pagamento.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}null é tratado pelo segundo argumento de useActionState.result.success.const CheckoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.coerce.number().int().min(1).max(99),
});startsWith("price_") garante que apenas IDs de preço válidos do Stripe sejam aceitos.z.coerce.number() converte a string do FormData em um número antes da validação./account reflete o status atualizado.Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥