pealdevDOCS
Back to peal.dev

STRIPE STARTER · PRICING CONFIGURATION

Pricing configuration

src/config/pricing.ts — the single source of truth for what the user sees AND what Stripe charges. Plans are a discriminated union; helpers do all the math.

Every plan in src/config/pricing.ts is either a one-time plan or a recurring plan. TypeScript narrows them via a discriminated union on type. The same file exports helpers that the UI uses for formatting, and a plans array the Server Action allowlists against.

The discriminated union

TypeScript
export interface OneTimePlan {
  type: "one-time";
  id: string;
  name: string;
  description: string;
  priceId: string;          // one Stripe Price ID
  price: number;            // display amount
  currency: string;
  features: PricingFeature[];
  highlighted?: boolean;
}

export interface RecurringPlan {
  type: "recurring";
  id: string;
  name: string;
  description: string;
  monthlyPriceId: string;   // two Stripe Price IDs
  yearlyPriceId: string;    // one per interval
  monthlyPrice: number;
  yearlyPrice: number;
  currency: string;
  features: PricingFeature[];
  highlighted?: boolean;
}

export type PricingPlan = OneTimePlan | RecurringPlan;

Always narrow with plan.type === "one-time" before touching plan-specific fields. TypeScript will keep you honest.

The helpers (use these, don't roll your own)

TypeScript
import {
  formatPrice,        // 29 + "usd" → "$29"
  getPriceId,         // plan + interval → Stripe Price ID
  getDisplayPrice,    // plan + interval → number
  getYearlySavings,   // recurring plan → % savings
} from "@/config/pricing";

formatPrice(29, "usd");           // "$29"
formatPrice(29.99, "usd");        // "$29.99"
formatPrice(1000, "eur");         // "€1,000"

getPriceId(plan, "monthly");      // "price_xxx" — or the one priceId for one-time
getDisplayPrice(plan, "yearly"); // number, not formatted
getYearlySavings(recurringPlan); // 17 (means 17% savings vs monthly × 12)

Adding a new plan

Add an entry to oneTimePlans or subscriptionPlans. The plans array is just the merged result of both — the Server Action validates against it.

TypeScript
// src/config/pricing.ts
export const subscriptionPlans: RecurringPlan[] = [
  // ...
  {
    type: "recurring",
    id: "team",
    name: "Team",
    description: "For small teams.",
    monthlyPriceId: "price_1AbCdEfGhIjKlMnOpQrStUv",
    yearlyPriceId:  "price_1AbCdEfGhIjKlMnOpQrStUw",
    monthlyPrice: 49,
    yearlyPrice: 490,
    currency: "usd",
    features: [
      { text: "Up to 5 seats", included: true },
      { text: "Audit log", included: false },
    ],
  },
];

Where each field shows up

  • name — card title.
  • description — subtitle under the title.
  • features — checklist below the price (greyed out when included: false).
  • highlighted: true — adds the "Most popular" treatment (use it on at most one plan).
  • monthlyPriceId / yearlyPriceId — what gets sent to Stripe. The user's interval toggle picks which one.

Replace every price_REPLACE_ placeholder before launch

We ship with placeholder IDs like price_REPLACE_WITH_PRO_MONTHLY_ID. They're intentional landmines — the Server Action allowlists against this file, so a forgotten placeholder means "Unknown price ID" at checkout. Better than charging $0 by accident.

Price → product, not the other way around

In Stripe, a Product has many Prices. Always create a NEW Price when you change the amount — never edit an old one. Old Prices stay valid for existing subscriptions; new customers get the new Price. This is why this template uses price IDs (price_xxx), not product IDs.

The currency field must match the Price in Stripe

Stripe is strict — if you create a Price in EUR but write currency: "usd" here, Checkout still uses EUR but your UI says "$". The mismatch is silent. Check the Price's currency in the Stripe dashboard before pasting the ID.