{
  "id": "add-a-pricing-page",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "en",
  "url": "/playbooks/add-a-pricing-page",
  "title": "Prompt-to-PR: Add a Pricing Page",
  "description": "SOP for generating a production pricing page with monthly/annual toggle, feature matrix, and Stripe Checkout links in a Next.js Tailwind project.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "tailwind",
    "typescript"
  ],
  "difficulty": "easy",
  "updated": "2026-06-08",
  "markdown": "One of the most-generated pages in AI coding sessions. This playbook constrains the agent to produce real, wired-up Stripe Checkout links rather than placeholder buttons.\n\n## 1. Requirement\n\nAdd a `/pricing` page with two or three tiers, a monthly/annual billing toggle (annual gives a 20% discount), a feature comparison matrix, and Stripe Checkout integration. Prices must come from environment variables, not be hardcoded in JSX.\n\n## 2. First Prompt\n\n```txt title=\"First Prompt\"\nAdd a /pricing page to this Next.js 15 App Router project with Tailwind.\n\nRequirements:\n1. Create `src/app/pricing/page.tsx` — a Server Component that renders\n   `<PricingCards>`.\n2. Create `src/components/PricingCards.tsx` — a Client Component with:\n   - Monthly/annual toggle (useState). Annual prices are monthly × 0.8.\n   - Three tiers: Hobby (free), Pro, Business. Read plan data from\n     `src/config/pricing.ts` — not hardcoded in JSX.\n   - A feature matrix table below the cards showing which features each tier\n     includes. Use checkmarks and dashes; do not use emojis in code.\n   - A \"Get started\" CTA for Hobby (links to /sign-up) and \"Subscribe\" for\n     paid tiers.\n3. Create `src/config/pricing.ts` exporting a `PLANS` array. Each plan has:\n   { id, name, monthlyPriceUsd, stripePriceIdMonthly, stripePriceIdAnnually,\n     features: string[], highlighted: boolean }\n   Read Stripe price IDs from env vars:\n     NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY\n     NEXT_PUBLIC_STRIPE_PRICE_PRO_ANNUALLY\n     NEXT_PUBLIC_STRIPE_PRICE_BIZ_MONTHLY\n     NEXT_PUBLIC_STRIPE_PRICE_BIZ_ANNUALLY\n4. Create `src/app/api/checkout/route.ts` (POST). Accept `{ priceId, userId }`.\n   Create a Stripe Checkout Session (mode: \"subscription\") with:\n   - success_url: NEXT_PUBLIC_APP_URL + /dashboard?checkout=success\n   - cancel_url: NEXT_PUBLIC_APP_URL + /pricing\n   Return `{ url }`.\n5. In PricingCards, the Subscribe button POSTs to /api/checkout and redirects\n   to the returned url.\n6. Install `stripe` package. Use env var STRIPE_SECRET_KEY.\n```\n\n## 3. Expected File Changes\n\n```txt\npackage.json                             (stripe)\nsrc/app/pricing/page.tsx                 (new — Server Component shell)\nsrc/components/PricingCards.tsx          (new — Client Component with toggle)\nsrc/config/pricing.ts                    (new — plan definitions)\nsrc/app/api/checkout/route.ts            (new — Stripe Checkout Session)\n.env.local.example                       (STRIPE_* and NEXT_PUBLIC_* vars)\n```\n\n## 4. Review Checklist\n\n- `STRIPE_SECRET_KEY` is never imported in a Client Component or exposed via `NEXT_PUBLIC_`.\n- The Checkout Session uses `mode: \"subscription\"` not `mode: \"payment\"` for recurring billing.\n- Annual price IDs are separate Stripe Price objects — not a computed discount in code.\n- The toggle shows annual savings clearly (e.g. \"Save 20%\").\n- The `PLANS` array is the single source of truth — feature matrix re-uses the same data, not a separate hardcoded table.\n- Error handling on the checkout POST: returns a 500 with a message if Stripe throws.\n- No `console.log` of Stripe keys or customer data left in the code.\n\n## 5. Test Commands\n\n```bash\nbun dev\n\n# Visit http://localhost:3000/pricing\n# Toggle monthly/annual — confirm prices update correctly\n# Confirm annual = monthly * 0.8\n\n# Test checkout endpoint with a test price ID\ncurl -X POST http://localhost:3000/api/checkout \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"priceId\":\"price_test_xxx\",\"userId\":\"user_1\"}' | jq .url\n# Expect a Stripe Checkout URL\n\nbun tsc --noEmit\n```\n\n## 6. Common Failures\n\n- **`STRIPE_SECRET_KEY` leaked client-side** — agent imports `stripe` in `PricingCards.tsx`. Move all Stripe calls to the API route.\n- **Annual toggle shows same price as monthly** — agent calculates `price * 0.8` but forgets to use the `isAnnual` state. Confirm the toggle state is passed to the price display.\n- **Checkout returns 500 with \"No such price\"** — price ID env var not set or wrong environment (test vs live). Verify with `stripe prices retrieve <id>` in the Stripe CLI.\n- **`success_url` is relative** — Stripe requires an absolute URL. Prefix with `NEXT_PUBLIC_APP_URL`.\n\n## 7. Fix Prompt\n\n```txt title=\"Fix Prompt\"\nThe annual/monthly toggle renders correctly visually but the Subscribe button\nalways sends the monthly priceId regardless of which toggle state is active.\n\nIn PricingCards.tsx, the `priceId` passed to the checkout POST must be\n`isAnnual ? plan.stripePriceIdAnnually : plan.stripePriceIdMonthly`.\nCheck that the isAnnual state variable is read at the point the button's\nonClick handler calls the checkout API.\n```\n\n## 8. PR Description\n\n```md title=\"PR description\"\n## Feature: Pricing page with Stripe Checkout\n\n- `/pricing` page with monthly/annual toggle and three tiers (Hobby, Pro, Business)\n- Feature matrix driven from `src/config/pricing.ts` — one source of truth\n- POST `/api/checkout` creates a Stripe Subscription Checkout Session\n- Price IDs read from env vars — no hardcoded Stripe IDs in code\n- Annual plan = 20% discount via separate Stripe Price objects\n\n**Required env vars**: `STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PRICE_*`,\n`NEXT_PUBLIC_APP_URL` (see `.env.local.example`)\n```"
}