Skip to content

A pricing page that renders with no database and no payment keys

Keep the plan catalogue in code, map provider price ids through env vars, and make /pricing a static page whose buttons are plain links to a checkout route.

Next.js on Vercel3 min readships at docs/solutions/nextjs-vercel/pricing-page-without-a-database.md

Tags: pricing · nextjs · billing · static-rendering · stripe · polar

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>

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:

  1. Unknown price: back to /pricing?error=unknown-price.
  2. 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.
  3. Already subscribed, or already owns the plan: to /billing with a notice.
  4. Provider keys or the price's env var missing: to /billing with "Billing is not set up yet", never a 500.
  5. 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, /billing and /llms.txt read one file and cannot disagree.
  • Switching payment provider changes env values, not the page.