Build a billing page
This guide adds a /billing page to the app from Add Stripe subscriptions to your app. The page shows the user’s plan, its price, and the next date that matters, such as a renewal or the end of a canceled plan, and sends the user to the right Stripe-hosted page. Finish that guide first: this one changes its lib/billing.ts and app/billing/actions.ts, and uses the same stack tabs.
One Strent call, customers.retrieveBillingState, returns everything the page shows. Strent has already worked out the dates and prices from Stripe’s raw fields, so your code only picks the words.
What the page shows
Section titled “What the page shows”The page reads the user’s billing state and shows the first row that matches:
| Billing state | The page says | The user’s next step |
|---|---|---|
| No Stripe customer, or no subscriptions | “You’re on the free plan” | Subscribe, which opens Stripe Checkout |
last_refreshed_at is null |
“We’re loading your billing details” | Refresh in a few seconds |
grants_access is false (status unpaid, paused, or incomplete) |
“Needs attention” | Complete the payment or manage it in the portal |
status is past_due |
“Your last payment failed” | Update the payment method in the portal |
cancellation is set |
Ends on <date> |
Renew it in the portal before that date, if they want |
status is trialing |
Free trial ends on <date> |
Nothing yet |
| Otherwise | Renews on <date> |
Nothing |
Each subscription also shows its plan and price, and a line for a pending_change: a change Stripe has scheduled for a future date, such as a switch to another price. A subscription can show both a change and an end date, for example a schedule that switches plans in March and ends the subscription in September. One Manage billing button below the list opens the Stripe Customer Portal, which handles every portal action in the table for all of the user’s subscriptions.
Offer Checkout only when the user has no subscriptions. Stripe keeps a subscription whose payment failed as unpaid, and Strent reports it with grants_access: false so the page can ask for payment: a new Checkout would create a second subscription and charge the user twice.
customers.retrieveBillingState is not cached and is slower than access checks. Call it on the billing page; keep checking access with userHasPro.
Let an AI agent follow this guide
Section titled “Let an AI agent follow this guide”Add a billing page to this app by followinghttps://docs.strent.dev/guides/build-a-billing-page/ in order. It builds onhttps://docs.strent.dev/add-stripe-subscriptions/, which must already beimplemented. Use the same stack tabs as that guide. Steps and the checkpointeach must pass:
1. Read the billing state -> the app starts and /export still works.2. Describe each subscription -> the project type-checks.3. Add the page and guard Checkout -> Stripe limits customers to one subscription, and a user who never subscribed sees "You're on the free plan" on /billing, also after leaving Checkout without paying.4. Subscribe -> /billing shows the Pro price and "Renews on" a date one month away.5. Cancel and renew in the Customer Portal -> /billing shows "Ends on" right after canceling, and "Renews on" again after renewing.
The Stripe setting in step 3 and steps 4 and 5 happen in the Stripe Dashboardor the browser: ask me to do them. Do not compute pricesor dates from Stripe fields. Stop and tell me which checkpoint failed if onedoes not pass.-
Read the billing state
Replace
lib/billing.tswith this version. The highlighted lines are new.lib/billing.ts import type { BillingState } from "@strent-dev/node";import { getStripeCustomerId, saveStripeCustomerId } from "./billing-store";import { strent } from "./strent";import { stripe } from "./stripe";export type AppUser = { id: string; email: string };export async function getOrCreateStripeCustomer(user: AppUser,): Promise<string> {const saved = await getStripeCustomerId(user.id);if (saved) return saved;const customer = await stripe.customers.create({ email: user.email, metadata: { app_user_id: user.id } },// A double click must not create two customers for one user.{ idempotencyKey: `create-customer-${user.id}` },);await saveStripeCustomerId(user.id, customer.id);return customer.id;}export async function createCheckoutUrl(user: AppUser): Promise<string> {const session = await stripe.checkout.sessions.create({mode: "subscription",customer: await getOrCreateStripeCustomer(user),line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],success_url: `${process.env.APP_URL}/billing/success`,cancel_url: `${process.env.APP_URL}/billing?from=stripe`,});return session.url!;}export async function createPortalUrl(user: AppUser): Promise<string> {const customer = await getStripeCustomerId(user.id);if (!customer) throw new Error("This user has no Stripe customer.");const session = await stripe.billingPortal.sessions.create({customer,return_url: `${process.env.APP_URL}/billing?from=stripe`,});return session.url;}// Throws when Strent cannot give a trustworthy answer; callers deny access.export async function userHasPro(user: AppUser): Promise<boolean> {const customer = await getStripeCustomerId(user.id);if (!customer) return false;return strent.activeItems.hasProduct({customer,product: process.env.STRIPE_PRO_PRODUCT_ID!,});}export async function refreshStripeCustomer(user: AppUser): Promise<void> {const customer = await getStripeCustomerId(user.id);if (customer) await strent.customers.refresh({ customer });}// null when the user never started Checkout, so no Stripe customer exists.// Throws when Strent cannot answer.export async function getBillingState(user: AppUser,): Promise<BillingState | null> {const customer = await getStripeCustomerId(user.id);if (!customer) return null;return strent.customers.retrieveBillingState({ customer });}// Checkout always creates a new subscription. Offer it only when Strent// confirms the customer has none: a user with an unpaid subscription would// otherwise pay twice.export function canStartCheckout(state: BillingState | null): boolean {return (state === null ||(state.last_refreshed_at !== null && state.subscriptions.length === 0));}getBillingStatereturnsnullfor a user who never started Checkout, without calling Strent, because that user has no Stripe customer. Otherwise it returns the customer’s billing state and throws when Strent cannot answer.canStartCheckoutallows Checkout only when Strent confirms there are no subscriptions. Whenlast_refreshed_atisnull, Strent has not read the customer from Stripe yet, so it cannot tell whether a subscription exists.- Stripe now sends the user back to
/billing?from=stripewhen they leave Checkout without paying (cancel_url) or leave the Customer Portal (return_url). The page refreshes the customer when it seesfrom=stripe, so it shows a cancellation made a second ago instead of waiting for Stripe’s webhook. Like the success page, it only refreshes the signed-in user’s stored customer; the URL carries no ID.
Checkpoint
The app starts, and
/exportstill shows Export PDF to a subscribed user. -
Describe each subscription
This module turns one subscription into three lines of text: its plan and price, its status, and a scheduled change, if any. It is plain TypeScript, so any server-side Node.js stack can use it.
lib/billing-display.ts import type { BillingPrice, BillingSubscription } from "@strent-dev/node";// Use your user's language here, and pass their timeZone to DateTimeFormat;// Intl adds the currency symbol, separators, and date order.const LOCALE = "en-US";export type SubscriptionSummary = {plan: string;status: string;change: string | null;};export function describeSubscription(subscription: BillingSubscription,): SubscriptionSummary {const pendingChange = subscription.pending_change;return {plan: describeItems(subscription.items),status: describeStatus(subscription),// Independent of a cancellation: a schedule can switch plans and later// end the subscription, and Strent reports both.change: pendingChange === null ? null : describeChange(pendingChange),};}// The first match wins, so the message the user must act on is never hidden.function describeStatus(subscription: BillingSubscription): string {// unpaid, paused, and incomplete each recover differently, so this promises// no specific outcome.if (!subscription.grants_access) {return "Needs attention: this subscription doesn't include access right now.";}if (subscription.status === "past_due") {return "Your last payment failed. Update your payment method.";}if (subscription.cancellation !== null) {const endsAt = subscription.cancellation.effective_at;return endsAt === null? "Scheduled to end.": `Ends on ${formatDate(endsAt)}.`;}if (subscription.status === "trialing" &&subscription.trial_end !== null) {return `Free trial ends on ${formatDate(subscription.trial_end)}.`;}return subscription.current_period_end === null? "Active.": `Renews on ${formatDate(subscription.current_period_end)}.`;}function describeChange(change: NonNullable<BillingSubscription["pending_change"]>,): string {const target = describeItems(change.items);return change.effective_at === null? `Changes to ${target}.`: `Changes to ${target} on ${formatDate(change.effective_at)}.`;}function describeItems(items: { price: BillingPrice; quantity: number | null }[],): string {return items.map(({ price, quantity }) => {const name = price.product.name ?? price.nickname ?? price.product.id;const times =quantity !== null && quantity > 1 ? ` × ${quantity}` : "";return `${name}${times}, ${formatPrice(price)}`;}).join(" + ");}export function formatPrice(price: BillingPrice): string {// null for tiered or usage-based prices, which have no single unit amount.if (price.unit_amount_display === null) return "custom pricing";const amount = new Intl.NumberFormat(LOCALE, {style: "currency",currency: price.currency,}).format(Number(price.unit_amount_display));const recurring = price.recurring;if (recurring === null) return amount;return recurring.interval_count === 1? `${amount} / ${recurring.interval}`: `${amount} every ${recurring.interval_count} ${recurring.interval}s`;}function formatDate(isoDate: string): string {return new Intl.DateTimeFormat(LOCALE, { dateStyle: "medium" }).format(new Date(isoDate),);}Prices use
unit_amount_display, the amount in whole currency units such as"19.99". Don’t divideunit_amountby 100 yourself: Stripe uses zero, two, or three decimals depending on the currency, sojpy2000 is ¥2,000, not ¥20. Every date comes from Strent as well:cancellation.effective_atalready accounts for each way Stripe can schedule an end. See Choose a runtime fact for each field.Checkpoint
The project type-checks, for example with
npx tsc --noEmit. -
Add the page and guard Checkout
The Subscribe button on
/exportalso starts Checkout. Thesubscribeaction now checks the billing state first and sends a user who already has a subscription, or whose state Strent cannot confirm, to/billing, where the page explains why.app/billing/page.tsx import type { BillingState } from "@strent-dev/node";import { redirect } from "next/navigation";import { Suspense } from "react";import { manageBilling, subscribe } from "@/app/billing/actions";import {canStartCheckout,getBillingState,refreshStripeCustomer,} from "@/lib/billing";import { describeSubscription } from "@/lib/billing-display";import { getCurrentUser } from "@/lib/current-user";type BillingPageProps = { searchParams: Promise<{ from?: string }> };export default function BillingPage({ searchParams }: BillingPageProps) {return (<Suspense fallback={<p>Loading your billing details…</p>}><Billing searchParams={searchParams} /></Suspense>);}async function Billing({ searchParams }: BillingPageProps) {const user = await getCurrentUser();if (!user) redirect("/auth/login");// Back from Checkout or the Customer Portal: show the change now instead of// waiting for Stripe's webhook.if ((await searchParams).from === "stripe") {try {await refreshStripeCustomer(user);} catch (error) {console.error("Strent refresh failed", error);}}let state: BillingState | null;try {state = await getBillingState(user);} catch (error) {console.error("Could not load the billing state", error);// The Customer Portal is hosted by Stripe and works without Strent.return (<form action={manageBilling}><p>We couldn't load your billing details, try again in a moment.</p><button type="submit">Manage billing</button></form>);}if (canStartCheckout(state)) {return (<form action={subscribe}><p>You're on the free plan.</p><button type="submit">Subscribe to Pro</button></form>);}// Strent has not read this customer from Stripe yet, so it cannot tell// whether a subscription exists.if (state === null || state.last_refreshed_at === null) {return (<p>We're loading your billing details, refresh in a few seconds.</p>);}return (<div>{state.subscriptions.map((subscription) => {const summary = describeSubscription(subscription);return (<section key={subscription.id}><h2>{summary.plan}</h2><p>{summary.status}</p>{summary.change && <p>{summary.change}</p>}</section>);})}<form action={manageBilling}><button type="submit">Manage billing</button></form></div>);}Replace
app/billing/actions.ts; the highlighted lines are new:app/billing/actions.ts "use server";import { redirect } from "next/navigation";import {canStartCheckout,createCheckoutUrl,createPortalUrl,getBillingState,} from "@/lib/billing";import { getCurrentUser } from "@/lib/current-user";export async function subscribe() {const user = await getCurrentUser();if (!user) redirect("/auth/login");// The billing page explains why when Checkout is not safe, including when// Strent cannot answer.const state = await getBillingState(user).catch(() => undefined);if (state === undefined || !canStartCheckout(state)) redirect("/billing");redirect(await createCheckoutUrl(user));}export async function manageBilling() {const user = await getCurrentUser();if (!user) redirect("/auth/login");redirect(await createPortalUrl(user));}Add a billing page at
/billingfor the signed-in user, sending signed-out users to your login:- When the query string has
from=stripe, callrefreshStripeCustomer(user), and log any error without failing. - Call
getBillingState(user). When it throws, log the error and show “We couldn’t load your billing details, try again in a moment” with a Manage billing button. The Customer Portal is hosted by Stripe and works without Strent. - When
canStartCheckout(state)returnstrue, show “You’re on the free plan” and a Subscribe to Pro button that posts to your Subscribe route. - When
state.last_refreshed_atisnull, show “We’re loading your billing details, refresh in a few seconds”. - Otherwise, for each subscription in
state.subscriptions, show theplan,status, andchangelines fromdescribeSubscription(subscription)(changecan benull), then one Manage billing button that posts to your Manage billing route.
In your Subscribe route, before redirecting to Checkout, call
getBillingState(user). When it throws orcanStartCheckout(state)returnsfalse, redirect to/billinginstead.The guard reads Strent, which can lag Stripe by a few seconds, and two open tabs can both pass it. Let Stripe make the final check: in the Stripe Dashboard, open Checkout and Payment Links settings, turn on Limit customers to one subscription, and choose to redirect customers to your website at
http://localhost:3000/billing?from=stripe(yourAPP_URL). Checkout then sends a customer who already has anactive,past_due,unpaid, orpausedsubscription to your billing page instead of taking a second payment. See Stripe’s guide to limiting customers to one subscription.Checkpoint
The Stripe setting is on. Signed in as a user who never subscribed,
/billingshows “You’re on the free plan” and a Subscribe to Pro button. Select it, then go back from Checkout without paying: you land on/billingwith the same message. - When the query string has
-
Subscribe and check the price and date
Checkpoint
On
/billing, select Subscribe to Pro and pay with the test card4242 4242 4242 4242. After the success page,/billingshows your Pro price, such as “Pro, $19.99 / month”, and “Renews on” a date one month from today. -
Cancel and renew in the Customer Portal
With the portal’s default settings, a cancellation takes effect at the end of the paid period, and until then the portal lets the user renew the subscription.
Checkpoint
On
/billing, select Manage billing, cancel the subscription, and return to your app with the portal’s back link./billingshows “Ends on” the same date it showed for the renewal, without a refresh. Open the portal again, renew the subscription, and return:/billingshows “Renews on” again.
Trials, scheduled changes, and payment problems
Section titled “Trials, scheduled changes, and payment problems”The other rows of the table need a different setup in Stripe:
- Trials start when Checkout creates the subscription with a trial, such as
subscription_data: { trial_period_days: 14 }instripe.checkout.sessions.create. See Stripe’s trial guide. - Scheduled changes come from a Stripe subscription schedule, which can switch prices or products on a future date. The Customer Portal can also schedule a downgrade for the end of the period, but only between prices of the same product, such as yearly to monthly Pro; see Stripe’s portal settings.
- Payment problems happen at renewal, when the card fails. Stripe’s subscription settings decide whether a failed renewal stays
past_dueor moves tounpaidorcanceled. To try them in test mode, advance time with Stripe’s test clocks.