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
"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
// 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
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.
// 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.