Products

Declare product slots in config.ts, read live store data through useProducts, and follow the three rules that keep prices honest.

Products connect your paywall to the things it sells. You declare them once in config.ts, and everything about them (price, period, trial) arrives from the store at runtime, localized and formatted for each user. You never hardcode a price.

Declare products

Products are slots. The key is the reference your code uses; the value is the store identifier:

import { definePaywall } from "superwall/config";

export default definePaywall({
  name: "Pro",
  products: {
    monthly: "pro_999_month",
    annual: "pro_5999_year",
  },
});

The shorthand string and the object form mean the same thing:

products: {
  annual: "pro_5999_year",                    // shorthand
  monthly: { productId: "pro_999_month" },    // same thing
},

Your code only ever speaks in references (getProduct("annual"), purchase("annual")), so swapping the underlying store product is a one-line config change.

One reference, a product per platform

A paywall shipped to several platforms usually sells a different store product on each: the App Store and Google Play never share an id. Keep the reference and give the slot one identifier per platform:

export default definePaywall({
  platforms: ["ios", "android", "web"],
  products: {
    monthly: "pro_999_month",                        // the same id on every platform
    annual: {
      ios: "pro_5999_year",
      android: "pro_5999_year_play",
      web: "live:price_1ABC…:7days-free",
    },
  },
});

purchase("annual") is the same call everywhere. Each platform's build resolves the slot to that platform's identifier (the per-platform map is rewritten out of the bundle, so an Android snapshot doesn't even contain the App Store id), and its version is pushed with the matching store (Play for Android). An Android paywall never names an App Store product, not even in the moment before the SDK delivers product data. A per-platform slot has to name every platform the paywall ships to, a push refuses annual has no product id for web rather than publish a slot that could never resolve. See Configuration.

Web and Stripe products

Web paywalls sell through Stripe, and the Stripe price lives inside the identifier, no separate mapping. The format is {environment}:{priceId}:{offer}, where {environment} is exactly test or live:

products: {
  monthly: "live:price_1ABC…:7days-free",
},

A paywall can declare both kinds side by side, store products for native, Stripe products for the web. See Web checkout for how the same purchase() call sells on both.

Product data never appears in the file

Price, period, and trial are store-owned and arrive at runtime, the same way they do for every Superwall paywall: the host loads the products, sends them with their data, and only then shows the paywall. On a device the host is the SDK; in a preview it is the studio, which reads your dashboard's products and sends nothing for a slot the dashboard cannot resolve. Until a host delivers them, useProducts() returns no products at all — the framework never invents one, in a preview or on a device. superwall push refuses to publish a reference the dashboard has no product for. Example identifiers in scaffolds and examples are placeholders to repoint at your own products.

Read product data

import { useProducts } from "superwall/hooks";

const { getProduct } = useProducts();
const annual = getProduct("annual");

annual?.variables.price          // "$59.99" — formatted for the user's region
annual?.variables.monthlyPrice   // "$5.00" — the store's own math
annual?.variables.trialPeriodDays

References are typed against your config, so a typo in getProduct("anual") is a compile error, not a runtime surprise.

The variables you'll reach for, all optional:

GroupVariables
Priceprice, rawPrice, currencyCode, currencySymbol
Periodperiod ("year"), periodAlt, localizedPeriod, periodly ("yearly"), periodDays, periodWeeks, periodMonths, periodYears
Per-interval pricedailyPrice, weeklyPrice, monthlyPrice, yearlyPrice
TrialtrialPeriodDays, trialPeriodWeeks, trialPeriodMonths, trialPeriodYears, trialPeriodPrice, rawTrialPeriodPrice, trialPeriodText ("7-day"), trialPeriodEndDate ("Jul 23, 2026"), per-interval trial prices
Localelocale, languageCode
Stateidentifier, isSubscribed

period and periodly arrive pre-localized to the device locale, "yearly" becomes "jährlich" on a German device, with no work on your side.

The three rules

Three habits keep product data honest.

1. Guard every read and design the unpriced state

A reference only exists once a host has delivered it, and its variables may still be missing: the SDK can send a product before its data, a dashboard sample may lack a field, and a slot the studio cannot resolve carries example values. Degrade the copy; never invent a number:

{annual?.variables.price ? `Subscribe · ${annual.variables.price}` : "Subscribe"}

The unpriced state isn't an error state. Your paywall will render it, so design it to read as intentional.

2. Number() before arithmetic

Numeric-looking variables arrive as strings on device ("59.99", "7"), while a dashboard sample in the studio may carry numbers. A typeof x === "number" check can pass in a preview and silently fail on a real phone, treating every product as trial-less:

const days = Number(annual?.variables.trialPeriodDays);
const trialDays = Number.isFinite(days) ? days : 0;

3. Display formatted, compute raw

Use price and monthlyPrice for copy. They're formatted by the store for the user's region and currency. Use rawPrice when you need to compute or animate. Never derive a displayed price the store already provides: your division will disagree with the store's own math somewhere in the world.

Selection state is ordinary React

The framework has no "selected plan" concept, selection is your state, typed against the config:

import { type ProductReference } from "superwall/hooks";

const [selected, setSelected] = React.useState<ProductReference>("annual");

The product-selection example shows the full pattern: a typed plan union, haptics.selection() on choice, real role="radiogroup" semantics, and a designed unpriced state.

Create the products on the dashboard

A push refuses if config.ts names a product the dashboard doesn't have, Stripe identifiers included. Store products can be created straight from the CLI; Stripe products are imported into the dashboard from Stripe instead, and the flags below don't apply to them.

superwall products create pro_5999_year \
  --name "Annual" --price 59.99 --period year \
  --trial-days 7 --entitlement <numeric-id>

See the CLI reference for the full flags. Once the products exist, continue to Purchases.

How is this guide?

On this page