Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Use Next.js Server Actions to handle all Stripe operations server-side. Combine with useActionState for form-based payment flows and Zod for input validation.
Create a Checkout Session 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 ?? "Invalid input" };
}
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-throw redirect errors (Next.js uses these internally)
if (err instanceof Error && err.message === "NEXT_REDIRECT") throw err;
return { error: "Failed to create checkout session. Please try again." };
}
}Create a 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), // Stripe minimum is 50 cents
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 ?? "Invalid input" };
}
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: "Failed to initialize payment." };
}
}Manage subscriptions 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: "Not authenticated" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "No active subscription" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "Failed to cancel subscription" };
}
}
export async function resumeSubscriptionAction() {
const session = await auth();
if (!session?.user?.id) return { error: "Not authenticated" };
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeSubscriptionId: true },
});
if (!user?.stripeSubscriptionId) {
return { error: "No subscription found" };
}
try {
await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: false,
});
revalidatePath("/account");
return { success: true };
} catch {
return { error: "Failed to resume subscription" };
}
}Full checkout flow using Server Actions and 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 ? "Redirecting to checkout..." : "Buy Now"}
</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: "Starter Kit",
description: "Everything you need to get started.",
priceId: "price_starter",
priceDisplay: "$19",
},
{
name: "Pro Bundle",
description: "Advanced tools for professionals.",
priceId: "price_pro",
priceDisplay: "$49",
},
{
name: "Enterprise Suite",
description: "Full-featured enterprise solution.",
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">Shop</h1>
<div className="grid md:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.priceId} {...product} />
))}
</div>
</div>
);
}Subscription management with 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">
Your subscription is set to cancel on{" "}
{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 ? "Resuming..." : "Resume Subscription"}
</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 ? "Canceling..." : "Cancel Subscription"}
</button>
</form>
{cancelState?.error && (
<p className="text-red-500 text-sm">{cancelState.error}</p>
)}
</div>
);
}useActionState (React 19) manages the form lifecycle: it tracks pending state, passes the previous result to the action, and re-renders when the action completes.(prevState, formData) => Promise<State> receives the previous return value and the submitted form data. This enables progressive error display without client-side state management.redirect() in a Server Action throws a special internal error. If you wrap the Stripe call in try/catch, you must re-throw redirect errors or the redirect will be silently swallowed.revalidatePath triggers re-fetching of server components on the specified path, keeping the UI in sync after mutations.Server Action with authenticated user context:
"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!);
}Combining Server Action with client-side Stripe.js:
"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>Initializing payment...</div>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm amount={amount} />
</Elements>
);
}useActionState is generic: useActionState<State>(action, initialState). The state type is inferred from the action's return type.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}redirect() throws a NEXT_REDIRECT error internally. If your Server Action has a try/catch around the Stripe call and the redirect, you must re-throw errors that are redirect errors. The simplest approach is to call redirect outside the try/catch block.useActionState replaces the older useFormState from react-dom. Use useActionState from react in React 19.isPending value from useActionState is true from the moment the form is submitted until the action completes (including any redirect). Use it to disable buttons and show loading states.useEffect, event handlers, or other client code by passing FormData manually.
| Approach | Pros | Cons |
|---|---|---|
| Server Actions + useActionState | Progressive enhancement, built-in pending state | Requires React 19 |
| Route Handlers (POST) | Works with any client, REST-style API | More boilerplate, no built-in form integration |
| tRPC mutations | Type-safe end-to-end, great DX | Additional dependency |
| Server Actions without useActionState | Simpler for redirect-only flows | No built-in error or pending state |
useActionState for built-in pending states.const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);state holds the previous action result (error messages or success data).isPending is true from form submission until the action completes.(prevState, formData) and returns the new state.redirect() throws a special NEXT_REDIRECT error internally.redirect outside the try/catch block.useActionState is imported from react (React 19) and replaces useFormState from react-dom.isPending return value that useFormState did not provide.useFormState in React 19 will be deprecated.cancel_at_period_end: true lets the user keep access until their current billing period ends.false before the period ends.revalidatePath("/account") refreshes the UI to reflect the change.useEffect, event handlers, or other client code.FormData object manually when calling outside a form context.createPaymentIntentAction to get a clientSecret from the server.clientSecret to the Elements provider as an option.CheckoutForm then uses Stripe.js to confirm the payment.type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}null initial state is handled by useActionState's second argument.result.success.const CheckoutSchema = z.object({
priceId: z.string().startsWith("price_"),
quantity: z.coerce.number().int().min(1).max(99),
});startsWith("price_") ensures only valid Stripe price IDs are accepted.z.coerce.number() converts the FormData string to a number before validation./account page reflects the updated status.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥