Ecommerce Boilerplate
Features

Payments (Stripe)

Optional, env-gated Stripe Checkout with server-side re-pricing and a webhook.

The boilerplate is quote-first: shoppers browse, add to cart, and submit a quote request — no payment required. Stripe Checkout is an optional, env-gated layer on top. Set STRIPE_SECRET_KEY and a real "Checkout" button appears next to the quote flow; leave it unset and the store behaves exactly as before.

Nothing about Stripe is baked into the build. The Stripe client is created lazily per request (src/lib/stripe.ts), and no /api route is ever statically generated, so STRIPE_SECRET_KEY is read at request time rather than frozen at build time. That used to be declared with export const dynamic = "force-dynamic"; the declarations were redundant and are gone, and tests/api-routes-dynamic.test.ts now checks the built route table instead — the outcome rather than the intent. A deployment without STRIPE_SECRET_KEY builds and runs fine.

How the store decides whether to show Checkout

The client never assumes Stripe is on. The cart asks the server first, via GET /api/checkout/config, which returns a single boolean:

GET /api/checkout/config
{ "enabled": true }

enabled is Boolean(process.env.STRIPE_SECRET_KEY) — it never exposes the key or any other secret:

src/lib/stripe.ts
export function isStripeEnabled(): boolean {
  return Boolean(process.env.STRIPE_SECRET_KEY);
}

When enabled is false, the cart renders only the quote flow and the Checkout button does not appear.

Creating a Checkout Session

POST /api/checkout turns a cart into a Stripe Checkout Session and returns the hosted-page URL to redirect to.

Request

POST /api/checkout
{
  "items": [
    { "id": "prod_abc", "quantity": 2 },
    { "id": "prod_xyz", "quantity": 1, "variantId": "var_red_m" }
  ],
  "customerId": "firebase-uid-optional"
}
FieldTypeRequiredNotes
itemsarrayYes1–100 lines.
items[].idstringYesCatalog product id, 1–128 chars.
items[].quantityintegerYesPositive, up to 10,000.
items[].variantIdstringNoSelected variant id, 1–128 chars.
customerIdstringNoFirebase uid of the signed-in shopper, 1–128 chars. Stored as the session's client_reference_id.

The body is validated with Zod; anything outside these bounds returns 400 Invalid request body, and a non-JSON body returns 400 Invalid JSON body.

Prices are never trusted from the client

The request carries only ids, quantities, and an optional variant id — no prices. For each line the server re-reads the product through the catalog seam, so it queries whichever backend the store runs on, and resolves the price and stock itself (src/lib/checkout-line.ts):

src/app/api/checkout/route.ts
const product = await getCatalogProduct(item.id);
if (!product) {
  return NextResponse.json(
    { error: `Product ${item.id} was not found.` },
    { status: 400 }
  );
}
const resolved = resolveCheckoutLine(product, item);

resolveCheckoutLine treats every stored field as untrusted and applies these rules:

  • Price comes from the product's stored price. When a variantId is present the variant's own price overrides it (falling back to the product price when the variant has none).
  • Missing/invalid price (not a finite number > 0) resolves to an error: "<name>" has no price — request a quote for it instead. Such products can only go through the quote flow.
  • Variant not found returns Variant "<id>" of "<product>" was not found.
  • Stock is checked server-side. A variant that tracks its own numeric stock is validated against that; otherwise the product-level stock applies. If stock < quantity the line fails with Not enough stock for "<name>": <n> available, <q> requested.
  • Line name is the product name, or "Product Name (Variant Name)" for a variant line. Two variants of one product become two separate line items.

The resolved price is then converted to the smallest currency unit and sent to Stripe as price_data, in the store's own base currency (siteConfig.currency.code, not a hardcoded "usd"), so Stripe only ever sees an amount the server computed:

src/app/api/checkout/route.ts
price_data: {
  currency: baseCurrency,
  unit_amount: Math.round(resolved.price * 100),
  product_data: {
    name: resolved.name,
    metadata: {
      productId: item.id,
      ...(item.variantId ? { variantId: item.variantId } : {}),
    },
  },
},

Session options

src/app/api/checkout/route.ts
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: lineItems,
  // Let shoppers enter Stripe promotion codes on the payment page.
  allow_promotion_codes: true,
  success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${origin}/cart`,
  metadata: encodeItemsMetadata(items),
  client_reference_id: customerId,
});
  • allow_promotion_codes: true — shoppers can enter Stripe promotion codes on the payment page.
  • success_url — /checkout/success?session_id={CHECKOUT_SESSION_ID} on the request origin.
  • cancel_url — back to /cart.
  • metadata — the cart's [id, quantity] / [id, quantity, variantId] entries are encoded as compact JSON (encodeItemsMetadata) so the webhook can rebuild the order later. Long carts spill losslessly into items_2, items_3, … keys to stay under Stripe's 500-char metadata limit.
  • client_reference_id — the optional customerId, used to attach the resulting order to a customer.

Response

200 OK
{ "url": "https://checkout.stripe.com/c/pay/cs_test_..." }

Redirect the shopper to url. Error responses:

StatusWhen
400Invalid JSON, invalid body, missing product, no price, missing variant, or insufficient stock.
429Rate limit exceeded (see below).
503{ "error": "checkout not configured" } — STRIPE_SECRET_KEY is not set.
500Stripe returned no session URL, or session creation threw.

Rate limiting

POST /api/checkout is rate-limited per client IP to 10 requests per 10 minutes. Over the limit it returns 429 with a Retry-After header:

429 Too Many Requests
{ "error": "Too many requests" }

The default limiter counts in-process, which on serverless means "N per instance per window" rather than a real limit. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to make it hold across instances — see rate limiting for why, and for what happens when the store is unreachable.

Example

Terminal
curl -X POST https://your-store.example.com/api/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{ "id": "prod_abc", "quantity": 2 }],
    "customerId": "firebase-uid-123"
  }'

See the checkout API reference for the full route contract.

The webhook: turning a payment into an order

POST /api/webhooks/stripe is where a paid session becomes a real order in the fulfillment pipeline. Point your Stripe webhook (or stripe listen) at it.

Signature verification

The endpoint requires both a Stripe client and STRIPE_WEBHOOK_SECRET; missing either returns 503 { "error": "webhook not configured" }. The raw request body is read as text and verified against the stripe-signature header:

src/app/api/webhooks/stripe/route.ts
const payload = await request.text();
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
  • A missing stripe-signature header → 400 Missing signature.
  • A failed verification → 400 Invalid signature.
  • Only checkout.session.completed events are acted on; any other verified event type is acknowledged with { "received": true }.

What it records

The route itself does no database work. It turns the verified Stripe session into a provider-neutral PaidSession — session id, buyer, line refs decoded from the metadata, and the amount Stripe actually charged — and hands it to commerce.orders.recordPaidSession() on the active backend, resolved by getServerCommerce(). A Postgres store records the order in Postgres, a Supabase store in Supabase, a Firebase store in Firestore.

src/app/api/webhooks/stripe/route.ts
const commerce = await getServerCommerce();
const { orderNumber, created } = await commerce.orders.recordPaidSession(
  toPaidSession(session)
);

Every adapter owes the same four things:

  1. Idempotency, keyed by the session id. Stripe retries deliveries, so a second delivery must return the first order rather than creating another. The SQL adapters get this from a UNIQUE orders.payment_session_id (a real constraint, so two concurrent deliveries cannot both win it); Firestore gets it from keying the order document by the session id.
  2. Line items rebuilt from your catalog. decodeItemsMetadata reconstructs the id/quantity/variantId refs, then buildPaidOrderItems enriches each one with productName, brand, price, subtotal, primaryImageURL and, for variant lines, variantId / variantName. A product deleted between checkout and delivery degrades to its id and a null price — the money has moved, so a thin record beats no record.
  3. The order. status: "Processing", paymentStatus: "paid", and the Stripe-authoritative total (amount_total / 100, net of any promotion code — which is why the line subtotals can sum higher). With a client_reference_id, a per-customer copy is written too.
  4. Stock, consumed atomically with the order. Lines are grouped per product; a variant line decrements the variant's own stock, falling back to product-level stock; untracked and unknown items are skipped. Prisma runs the whole thing in one transaction and takes SELECT … FOR UPDATE locks first, so two deliveries cannot both spend the last unit. Firestore reads inside its transaction, which gives the same guarantee.

Supabase is the exception on point 4. PostgREST offers no interactive transaction and no row locks, so its decrement is best-effort and separate from the insert: a failure is logged and never fails the recorded order, and two sessions decrementing the same product at the same instant can lose one update. Idempotency still holds — that rests on the unique constraint. A store that needs the stricter guarantee should point Prisma at the same database.

Existing SQL deployments must migrate. The payment_session_id / payment_status columns are new. Run npm run prisma:push (Postgres) or re-run supabase/schema.sql (Supabase) — both are idempotent — before the first paid webhook arrives, or recording fails with an unknown-column error.

On the Firebase backend, order writes need Admin credentials. If the event verifies but none are configured, the handler still returns 200 with { "received": true, "recorded": false } and logs that the payment completed but the order could not be recorded. Verified events always get a 200 so Stripe doesn't retry indefinitely — check your logs if orders aren't appearing.

Responses

BodyMeaning
{ "received": true, "recorded": true, "orderNumber": "…", "created": true }Order recorded.
{ "received": true, "recorded": true, "created": false }A retried delivery; the existing order is returned.
{ "received": true, "recorded": false }Verified, but recording failed (logged) — missing credentials, an unmigrated schema, or an unknown COMMERCE_BACKEND.
{ "received": true }Verified event of a type other than checkout.session.completed.

Full contract: webhooks API reference.

Setup

Set your Stripe secret key

Add your key to .env.local. This alone flips GET /api/checkout/config to { "enabled": true } and makes the Checkout button appear.

.env.local
STRIPE_SECRET_KEY=sk_test_...

Forward webhooks and grab the signing secret

In development, use the Stripe CLI to forward checkout.session.completed events to your local route. The command prints a signing secret (whsec_...) — copy it into STRIPE_WEBHOOK_SECRET.

Terminal
stripe listen --forward-to localhost:3000/api/webhooks/stripe
.env.local
STRIPE_WEBHOOK_SECRET=whsec_...

In production, create the webhook endpoint in the Stripe Dashboard pointing at https://your-store.example.com/api/webhooks/stripe and use the endpoint's signing secret instead.

Give the webhook write access to your backend

The webhook writes with the same server credentials the rest of the server uses, so if your backend already works there is nothing new to add.

DATABASE_URL (or SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY) is all the recording needs. One migration is required the first time, because the payment columns are new:

Terminal
npm run prisma:push          # Postgres
# or re-run supabase/schema.sql in the Supabase SQL editor

See environment variables for the full list, including how FIREBASE_SERVICE_ACCOUNT_JSON and FIREBASE_SERVICE_ACCOUNT relate.

On this page