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
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)
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.
// 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.