Stripe with Server Actions
Recipe
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" };
}
}Working Example
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>
);
}Deep Dive
How It Works
- Server Actions run exclusively on the server. Stripe's secret key is never exposed to the client, and all API calls happen in a secure server context.
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.- The action signature
(prevState, formData) => Promise<State>receives the previous return value and the submitted form data. This enables progressive error display without client-side state management. - Zod validation in Server Actions provides type-safe input parsing. If validation fails, return the error as state rather than throwing.
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.revalidatePathtriggers re-fetching of server components on the specified path, keeping the UI in sync after mutations.
Variations
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>
);
}TypeScript Notes
useActionStateis generic:useActionState<State>(action, initialState). The state type is inferred from the action's return type.- Define explicit return types on Server Actions for clarity.
- Use discriminated unions for action results to make success and error states type-safe.
type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}Gotchas
redirect()throws aNEXT_REDIRECTerror 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 callredirectoutside the try/catch block.- Server Actions are POST requests under the hood. They have a default body size limit. This is fine for Stripe operations but be aware if sending large payloads.
useActionStatereplaces the olderuseFormStatefromreact-dom. UseuseActionStatefromreactin React 19.- The
isPendingvalue fromuseActionStateistruefrom the moment the form is submitted until the action completes (including any redirect). Use it to disable buttons and show loading states. - Server Actions can be called directly (not just from forms). You can call them from
useEffect, event handlers, or other client code by passingFormDatamanually. - Always validate inputs server-side with Zod even if you validate client-side. Client-side validation can be bypassed.
Alternatives
| 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 |
FAQs
Why use Server Actions instead of Route Handlers for Stripe operations?
- Server Actions integrate directly with React forms and
useActionStatefor built-in pending states. - They support progressive enhancement -- forms work even before JavaScript loads.
- Less boilerplate than creating separate API route files.
How does useActionState manage the checkout form lifecycle?
const [state, formAction, isPending] = useActionState(
createCheckoutAction,
null
);stateholds the previous action result (error messages or success data).isPendingis true from form submission until the action completes.- The action receives
(prevState, formData)and returns the new state.
Why is Zod validation important in Server Actions even if you validate client-side?
- Client-side validation can be bypassed by modifying the request directly.
- Server Actions are POST requests -- anyone can call them with arbitrary data.
- Zod provides type-safe parsing that returns structured errors on failure.
Gotcha: What happens if you catch a redirect error inside a Server Action?
redirect()throws a specialNEXT_REDIRECTerror internally.- If your try/catch swallows it, the redirect is silently lost and the user stays on the page.
- You must re-throw redirect errors or call
redirectoutside the try/catch block.
Gotcha: What is the difference between useActionState and the older useFormState?
useActionStateis imported fromreact(React 19) and replacesuseFormStatefromreact-dom.- It adds the
isPendingreturn value thatuseFormStatedid not provide. - Using
useFormStatein React 19 will be deprecated.
How does the subscription cancellation flow use cancel_at_period_end?
- Setting
cancel_at_period_end: truelets the user keep access until their current billing period ends. - The user can resume by setting it back to
falsebefore the period ends. revalidatePath("/account")refreshes the UI to reflect the change.
Can Server Actions be called outside of a form element?
- Yes, you can call them from
useEffect, event handlers, or other client code. - Pass a
FormDataobject manually when calling outside a form context. - This is useful for combining Server Actions with client-side Stripe.js.
How do you combine a Server Action with client-side Stripe Elements?
- Call
createPaymentIntentActionto get aclientSecretfrom the server. - Pass the
clientSecretto theElementsprovider as an option. - The client-side
CheckoutFormthen uses Stripe.js to confirm the payment.
How should you type the return value of a Server Action using discriminated unions?
type ActionResult =
| { success: true; data: string }
| { success: false; error: string }
| null;
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
// ...
}- The
nullinitial state is handled byuseActionState's second argument. - TypeScript narrows the type when you check
result.success.
How does the CheckoutSchema validate the priceId format?
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.
What does revalidatePath do after a subscription mutation?
- It tells Next.js to re-fetch server components on the specified path.
- After canceling or resuming a subscription, the
/accountpage reflects the updated status. - Without it, the user would see stale data until the next full page load.
Why does the ProductCard use hidden form inputs for priceId and quantity?
- Hidden inputs include the data in the FormData submitted to the Server Action.
- This avoids passing sensitive values through client-side JavaScript state.
- The form works with progressive enhancement -- it submits even without JS.