React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripeServer ActionsuseActionStateZodforms

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.
  • revalidatePath triggers 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

  • useActionState is 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 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.
  • 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.
  • useActionState replaces the older useFormState from react-dom. Use useActionState from react in React 19.
  • The 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.
  • Server Actions can be called directly (not just from forms). You can call them from useEffect, event handlers, or other client code by passing FormData manually.
  • Always validate inputs server-side with Zod even if you validate client-side. Client-side validation can be bypassed.

Alternatives

ApproachProsCons
Server Actions + useActionStateProgressive enhancement, built-in pending stateRequires React 19
Route Handlers (POST)Works with any client, REST-style APIMore boilerplate, no built-in form integration
tRPC mutationsType-safe end-to-end, great DXAdditional dependency
Server Actions without useActionStateSimpler for redirect-only flowsNo 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 useActionState for 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
);
  • state holds the previous action result (error messages or success data).
  • isPending is 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 special NEXT_REDIRECT error 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 redirect outside the try/catch block.
Gotcha: What is the difference between useActionState and the older useFormState?
  • useActionState is imported from react (React 19) and replaces useFormState from react-dom.
  • It adds the isPending return value that useFormState did not provide.
  • Using useFormState in React 19 will be deprecated.
How does the subscription cancellation flow use cancel_at_period_end?
  • Setting cancel_at_period_end: true lets the user keep access until their current billing period ends.
  • The user can resume by setting it back to false before 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 FormData object 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 createPaymentIntentAction to get a clientSecret from the server.
  • Pass the clientSecret to the Elements provider as an option.
  • The client-side CheckoutForm then 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 null initial state is handled by useActionState'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 /account page 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.