Most SaaS starters read their plans from the payment provider or from a
plans table the provider syncs into. That looks tidy until the first
preview deploy: no Stripe key, empty table, and the most important marketing
page in the product is a 500 or an empty grid.
Put the catalogue in code
// src/lib/pricing.ts
export const pricing = {
currency: "usd",
plans: [
{ slug: "free", name: "Free", prices: [], cta: { label: "Start for free", href: "/sign-up" }, ... },
{ slug: "pro", name: "Pro", highlighted: true, prices: [
{ id: "pro-monthly", interval: "month", amount: 1900, trialDays: 7 },
{ id: "pro-yearly", interval: "year", amount: 19000 },
{ id: "pro-lifetime", interval: "one_time", amount: 29900 },
], ... },
],
faq: [...],
} as const satisfies PricingCatalog;
Plans are product decisions: names, features, what is highlighted. They belong in version control, reviewed like any other change, and they render identically on a laptop with nothing configured.
What must not live in code is the provider's id for each price, because test mode and live mode have different ids. Give every price its own env var, named after the price id, and read them literally:
// src/lib/billing/price-refs.ts
export const priceRefs = {
"pro-monthly": process.env.BILLING_PRICE_PRO_MONTHLY,
"pro-yearly": process.env.BILLING_PRICE_PRO_YEARLY,
"pro-lifetime": process.env.BILLING_PRICE_PRO_LIFETIME,
} satisfies Record<PriceId, string | undefined>;
satisfies Record<PriceId, ...> turns "added a price, forgot its env var"
into a type error. Literal reads matter too: process.env[name] hides the key
from tools that check which variables a repo uses, and Next.js only inlines
literal reads.
Keep the display amount honest
The amount in code is what the page shows; the provider charges its own price. They will drift the day someone edits one side. A sync script that compares both (amount, currency, interval) and exits non-zero on a mismatch is cheap insurance. Run it in your deploy checklist.
Make the page static on purpose
/pricing must not call cookies(), headers(), the session, the database
or the provider. Any one of those makes it dynamic, and some of them throw
without configuration. Static means it is a file on the CDN, the same for
every visitor, and it cannot break.
Anything visitor-specific comes from the URL, read in a small client island:
<Suspense fallback={null}>
<PricingNotice /> {/* useSearchParams(): "Checkout cancelled", "This needs Pro" */}
</Suspense>
Buttons are links to a checkout route
The page does not know who is looking, so every paid button is the same
link: <a href="/billing/checkout?price=pro-monthly">. The route handler
does the personal part:
- Unknown price: back to
/pricing?error=unknown-price. - Signed out: to sign-up with the checkout URL as the return path. After sign-up they land back on the same URL and go straight on to pay.
- Already subscribed, or already owns the plan: to
/billingwith a notice. - Provider keys or the price's env var missing: to
/billingwith "Billing is not set up yet", never a 500. - Otherwise: create the checkout session and answer 303 to it.
Use a plain <a>, not next/link. Link prefetches in the viewport, and a
prefetch of a GET that creates checkout sessions creates checkout sessions.
GET is still right here: it is what survives the sign-up round trip, and the
provider's page is where money moves, after the user types a card.
What you get
- A fresh clone, a preview deploy and CI all render real prices.
- The landing page's pricing section,
/pricing,/billingand/llms.txtread one file and cannot disagree. - Switching payment provider changes env values, not the page.