Subscription management UI
Build an account page — show status and a cancellation banner, cancel, uncancel, change plan, and open the portal.
An account page needs to answer three questions — am I subscribed?, what am I paying?, and what can I change? — and offer four actions: cancel, uncancel, change plan, and manage billing details.
Load the state
import { createClient } from 'revenue-sdk';
import { polar } from 'revenue-sdk/polar';
const client = createClient({
provider: polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }),
});
export async function loadBilling(userId: string) {
const user = await getUser(userId);
if (!user.providerSubscriptionId) {
return { subscription: undefined, plans: await loadPlans() };
}
const subscription = await client.subscriptions.get({ id: user.providerSubscriptionId });
return { subscription, plans: await loadPlans() };
}
Show status honestly
The unified status model makes this a lookup rather than a per-provider branch:
import type { Subscription } from 'revenue-sdk';
const LABELS: Record<Subscription['status'], string> = {
incomplete: 'Awaiting first payment',
trialing: 'Trial',
active: 'Active',
past_due: 'Payment failed — retrying',
unpaid: 'Payment failed',
paused: 'Paused',
canceled: 'Ended',
};
const ENTITLED: ReadonlySet<Subscription['status']> = new Set(['active', 'trialing', 'past_due']);
export function statusView(subscription: Subscription) {
return {
label: LABELS[subscription.status],
entitled: ENTITLED.has(subscription.status),
renewsAt: subscription.cancelAtPeriodEnd ? undefined : subscription.currentPeriodEnd,
endsAt: subscription.cancelAtPeriodEnd ? subscription.endsAt : undefined,
};
}
The cancellation banner
A scheduled cancellation keeps status unchanged, so the banner keys off cancelAtPeriodEnd, not the
status:
export function CancellationBanner({ subscription }: { subscription: Subscription }) {
if (!subscription.cancelAtPeriodEnd) return null;
return (
<aside role="status">
<p>
Your subscription ends on{' '}
<strong>{subscription.endsAt?.toLocaleDateString() ?? 'the end of this period'}</strong>. You
keep full access until then.
</p>
<form method="post" action="/api/billing/uncancel">
<button type="submit">Resume subscription</button>
</form>
</aside>
);
}
status === 'canceled' is a different, terminal state — show a “start a new subscription” call to
action there, not a resume button. See
Subscription lifecycle.
Cancel
// POST /api/billing/cancel
import { RevenueError } from 'revenue-sdk';
export async function POST(request: Request): Promise<Response> {
const user = await requireUser(request);
const form = await request.formData();
const reason = form.get('reason');
try {
const subscription = await client.subscriptions.cancel({
id: user.providerSubscriptionId!,
// Only send the reason where the provider forwards it.
reason: client.capabilities.cancellationReason ? toReason(reason) : undefined,
});
await upsertSubscription(subscription);
return Response.redirect('/account/billing', 303);
} catch (error) {
if (error instanceof RevenueError) {
return new Response(error.message, { status: 400 });
}
throw error;
}
}
reason must be one of the unified CancellationReason values — customer_service, low_quality,
missing_features, other, switched_service, too_complex, too_expensive, unused — and requires
the cancellationReason capability (Lemon Squeezy and Paddle lack it).
To end access immediately instead, use subscriptions.revoke — gated by the revoke capability,
which Lemon Squeezy lacks:
if (client.capabilities.revoke) {
await client.subscriptions.revoke({ id: subscriptionId });
}
Uncancel
// POST /api/billing/uncancel
const subscription = await client.subscriptions.uncancel({ id: user.providerSubscriptionId! });
await upsertSubscription(subscription);
All five providers support it. It reverts a scheduled cancellation only — once status is canceled
the subscription is gone and the customer needs a new checkout.
Change plan
Offer only the plans the customer isn’t already on, and pass checkoutRef — never Product.id:
export function PlanSwitcher({ subscription, plans }: Props) {
const proration = client.capabilities.prorationBehaviors.includes('prorate')
? 'prorate'
: 'invoice_now';
return (
<form method="post" action="/api/billing/change-plan">
<input type="hidden" name="prorationBehavior" value={proration} />
{plans
.filter((plan) => plan.checkoutRef !== subscription.priceId)
.map((plan) => (
<button key={plan.checkoutRef} name="checkoutRef" value={plan.checkoutRef} type="submit">
Switch to {plan.name} — {plan.price}
</button>
))}
</form>
);
}
// POST /api/billing/change-plan
const subscription = await client.subscriptions.changePlan({
id: user.providerSubscriptionId!,
product: checkoutRef,
prorationBehavior,
});
await upsertSubscription(subscription);
Pick the proration behavior from capabilities.prorationBehaviors rather than hard-coding it — Polar
has no none, Dodo Payments has no prorate. The full matrix is in
Subscription lifecycle.
The billing portal link
Payment methods, invoices, and tax details belong in the provider’s portal — don’t rebuild them. Mint the session on click, because the URLs expire:
// GET /api/billing/portal
export async function GET(request: Request): Promise<Response> {
const user = await requireUser(request);
const session = await client.customerPortal.createSession({
customerId: user.providerCustomerId!,
returnUrl: client.capabilities.portalReturnUrl
? 'https://example.com/account/billing'
: undefined,
});
return Response.redirect(session.url, 302);
}
<a href="/api/billing/portal">Manage billing details</a>
Refresh state after every mutation
Every mutating call returns the updated Subscription. Write it straight back so the page reflects the
change without waiting for the webhook — the webhook will arrive shortly after and converge on the same
state, which is exactly why the handler upserts.
const subscription = await client.subscriptions.cancel({ id });
await upsertSubscription(subscription); // same function the webhook uses