pealdevDOCS
Back to peal.dev

STRIPE STARTER · SERVER ACTIONS

Server Actions

createCheckoutSession and createPortalSession in src/actions/checkout.ts. Server Actions are the only way to create Stripe sessions in this template. Never create an API route for this.

Server Actions look like normal async functions you call from a client component, but they execute on the server. No API contract to maintain, no fetch wrapper to write, no JSON to parse. They return typed values; TypeScript checks both sides.

createCheckoutSession — the flow

  • Extract the client IP from x-forwarded-for / x-real-ip.
  • Check rate limit (no-op if Upstash isn't configured).
  • Zod-validate the price ID format.
  • Allowlist check: the price ID MUST exist in plans from src/config/pricing.ts. Random Stripe Price IDs are rejected.
  • Decide mode: if the matched plan is recurring → "subscription", else "payment".
  • Create the session with line_items, success_url, cancel_url, and plan metadata.
  • Return the discriminated union: { success: true, url } or { success: false, error }.

Calling it from a client component

TypeScript
"use client";
import { createCheckoutSession } from "@/actions/checkout";

async function onClick() {
  const result = await createCheckoutSession("price_1AbCd...");
  if (!result.success) {
    setError(result.error); // "Too many requests...", "Unknown price ID", etc.
    return;
  }
  window.location.href = result.url; // redirect to hosted Checkout
}

The discriminated union return type

TypeScript
// src/types/stripe.ts
export type CheckoutResult =
  | { success: true;  url: string }
  | { success: false; error: string };

// TypeScript forces you to check result.success before reading
// result.url or result.error. No silent typos, no try/catch theatre.

createPortalSession — the same shape

TypeScript
import { createPortalSession } from "@/actions/checkout";

const result = await createPortalSession("cus_xxx", "/dashboard");
if (result.success) window.location.href = result.url;

Plan metadata — read this in your webhook

Every checkout session we create attaches planId metadata to the resulting subscription or payment intent. That's the bridge from Stripe back to your config — when the webhook fires, you can look up the plan without re-deriving it from the price ID.

TypeScript
// In createCheckoutSession
...(isRecurring && {
  subscription_data: { metadata: { planId: plan.id } },
}),
...(!isRecurring && {
  payment_intent_data: { metadata: { planId: plan.id } },
}),

// In your webhook handler
const planId = subscription.metadata.planId; // "starter" | "pro" | ...

NEVER create a /api/checkout route

Server Actions are the contract. Adding an API route would mean a JSON schema to maintain, CORS to think about, and a second code path. The ONLY API route in this project is /api/webhooks/stripe — and that's because Stripe POSTs to a URL, not a Server Action.

The Server Action never throws — it returns

We deliberately catch the Stripe error and return { success: false, error }. Throwing inside a Server Action bubbles up as a stringified error to the client, which then has to play guessing games. Discriminated unions are friendlier on both ends.

Why the allowlist check matters

Without it, a malicious user could trigger createCheckoutSession with ANY Stripe Price ID in your account — including a $0.01 test price you forgot to archive. The check against plans means only prices you've explicitly listed are sellable.