How to change pricing without affecting existing customers in Next.js
Change SaaS pricing without affecting existing customers: publish a new plan version, leave subscriptions pinned to the old one, and decide what a v1 customer sees when you ship v3.
In short
Changing a price never means editing one. Publish a new plan version, leave the old one attached to existing subscriptions, and both keep billing correctly. The hard part is not the price. It is whether a customer on version 1 gets the feature you shipped in version 3, which your entitlement check decides.
The price change is the easy half. Publish new numbers, leave existing subscriptions where they are, and both keep billing correctly. Most billing systems will do that much.
Then you ship a feature six weeks later and have to answer a question that never came up in the pricing meeting. Does the customer who signed up in March get it?
That is not a pricing decision. It is decided by which object your entitlement check reads, and you made that decision months ago without noticing.
What you are building
A pricing change that leaves every existing subscriber where they are, and an entitlement check that keeps them there on purpose rather than by accident.
When it works, three things are true at once. Your pricing page offers the new
version. A workspace that subscribed last month still reports version 1 from
bb.subscription.get, still bills the old amount, and still resolves its
features from the version it signed up to. And nothing you deployed moved it.
Before you start
A BuildBase project with the billing module, a plan that already has one published version, and at least one workspace subscribed to it. If nobody has subscribed yet, skip this entire guide: a draft version is freely editable, so change the numbers and publish. Versioning is for plans that already have customers on them.
Step one: create the next version from the current one
Create the new version from the one you are replacing. Post to the plan's versions route naming the version you want copied:
// POST /api/subscriptions/plans/:planId/versions
{
"baseVersionId": "<the currently published version id>"
}baseVersionId is optional, and leaving it out is rarely what you want: you get
an empty draft with no features, no limits, no quotas and no prices, which is a
new plan wearing a version number. Name the version you are copying.
You get back a new row: the next version number, status draft. The copy brings
across subscription items, features, limits, the quota values (included,
overage, unitSize), and the pricing amounts, trial and credit grant. Not the
Stripe price ids. Those get minted fresh on publish, and that omission is the
whole reason a copy is safe.
Two refusals to expect here. A plan can hold one draft at a time, so a second
POST fails while an unpublished draft exists. And the version number comes from
the highest existing version plus one, with a unique index on plan and version as
the race guard, so two people clicking at once produces one version and one
error rather than two v3s.
Step two: edit the draft, because the published version will refuse you
Put the new price on the draft. If you aim the same edit at the published version instead, the route rejects it:
Cannot modify pricing variants for a published version. Create a new version or change status to draft first.
That refusal covers subscriptionItems, features, limits, quotas and
pricingVariants. The priced surface of a published version is frozen, and that
is the sentence the rest of the guide rests on: "what was this customer sold?"
has an answer that is a row, not a reconstruction from invoices.
Know where the freeze stops, though. Alongside the lifecycle flags you would
expect, trial and creditGrant still pass through on a published version. A
trial length, whether it asks for a
card, the credits that come with the
plan: those are commercial terms by any
reasonable reading, and they are editable in place. Treat the guard as protecting the price and the entitlements,
not everything a customer agreed to.
Step three: publish, and watch the old version go legacy
Publishing runs a short list of guards first. Only a draft can publish. Only the highest version number can publish, so you cannot resurrect v2 after v3 exists. The version needs at least one subscription item. And it needs a pricing variant for every currency your organization bills in, which is the one to watch: the bar is set by your organization's currency list rather than by the version you copied from, so a currency added after v1 shipped is a currency v2 has to carry.
Then the supersession happens in one pass. The previously current version is marked not-current, flagged legacy, and stamped with a deprecation time. Its replacement becomes current and gets a publish time, and the plan's pointer to its latest version moves with it.
Now the important part, which is a thing that does not happen: nothing writes to any existing subscription. No loop over subscribers, no migration job. Grandfathering here is not a feature you enable. It is the absence of a write, which is why it cannot half-fail the way a migration can.
Step four: read what a subscriber is actually on
In your app, the subscription tells you its version directly:
import BuildBase from '@buildbase/sdk';
const bb = BuildBase({ serverUrl, orgId, getSessionId });
const { subscription, plan, planVersion } =
await bb.subscription.get(workspaceId);plan is the stable thing the customer would name out loud, like Launch. It
carries a name, a slug and a pointer to the newest version, and that is roughly
all it carries. Price, features, limits and quotas live only on planVersion.
There is nothing on a plan to mutate, which is less a design decision than the
consequence of one.
So a workspace that subscribed to Launch at $49 and a workspace that subscribes tomorrow are both on Launch. They are not on the same version, and every number that matters hangs off the version.
Step five: resolve entitlements from the version, not the plan
Here is the line that decides whether your grandfathering survives contact with your roadmap:
// Right: reads what this customer actually bought.
const canExport = planVersion.features['csv-export'] === true;
// Wrong: reads what you are selling today.
const canExport = plan.latestVersion.features['csv-export'] === true;The second one works perfectly until the day you publish v2. Then every legacy subscriber silently inherits whatever you just put on the newest version, and you find out from a support conversation rather than a test. An entitlement resolves from a published plan version, and the version a subscription is pinned to is the only correct source.
Reading from the version is not the same as nothing being stored. Assignment writes a copy of the features and the limits onto the workspace, and the subscription itself is cached for ten seconds. What saves you is the read order, not the absence of a snapshot: features come back as defaults, then the workspace's copy, then the pinned version last, so the version wins every collision. Quotas are computed from the version the same way.
Limits are the exception, and that one has teeth.
The failure modes
Limits never read from the version. Features and limits are both written onto
the workspace when a plan is assigned, so far so symmetrical. Quotas are never
written there at all: workspace.quotas holds usage counters, not allowances,
and the allowance is computed from the version on every read. The difference
shows on the way out. Features resolve through the pinned version, which gets the
last word, and quotas come straight from it. Limits are returned from the
workspace and the version is never consulted.
Most of the time you will not notice, because a plan change re-syncs the limits the new version defines. The case that bites is a workspace with no live subscription. Cancel one and its plan-managed features fall back to their defaults, exactly as you would want, while its limits sit there at the numbers its last paid plan wrote. A canceled workspace loses the features it stopped paying for and keeps the limits. If you are reasoning about "what does this customer get", you are reasoning about two mechanisms wearing one name.
The pricing page shows the current group, not a customer's version. The public plans endpoint filters to the published, current, unarchived group version and then takes whichever plan versions that group pins. That is correct for a pricing page and wrong as a way to answer "what is this customer on", because a legacy subscriber's version is reachable through their subscription and nowhere else. A billing settings screen built from the pricing list will show a grandfathered customer a plan they are not on.
You cannot archive a version that still has subscribers. The route refuses it and tells you why: active subscriptions still point at that version. Active here is broader than you would guess: past-due, paused, incomplete and unpaid subscriptions all count, not just active and trialing. This is a good guard. It is also worth knowing before you plan a tidy-up, because a version with one unpaid subscriber on it is a version you keep.
Deleting the plan is not guarded the same way. The archive check exists on plan versions, on group versions and on pricing groups. The plan delete route soft-archives without checking for active subscriptions at all. We found that asymmetry writing this guide, and no test covers either guard, so treat the protection you get on versions as a property of that one route rather than a rule the billing service enforces everywhere.
Neither route deactivates the Stripe prices, either. A helper to do it exists on the plan-version controller and nothing calls it, so archiving in our console leaves live prices behind in Stripe. Harmless while nothing references them, and worth knowing before you assume archived means gone on both sides.
There is no "migrate everyone" button. In a deployed app, one thing repoints a subscription at a different plan version: the customer changing plan themselves. Publishing a group version does sweep every affected subscription, but the only field it writes is the group pointer, and only for subscriptions whose pinned version is in the new group. The rest keep the legacy version on purpose, and none of them changes plan version at all.
There is a third path in the codebase that assigns a plan to a workspace
directly, and it is worth knowing it is not an option: it is test scaffolding,
mounted only when NODE_ENV is not production. If you go looking for an admin
override because a customer asked to be moved, that is the route you will find,
and it is not there in the environment you need it in.
So a price rise reaches new subscribers immediately, existing ones one at a time, and everybody else never. That is the correct default. It is also the thing to say out loud in the pricing meeting, because "we raised prices" and "our revenue per customer went up" are separated here by however long it takes people to choose to move.
When not to version
Versioning has a real cost. Every published version is a row you keep, a set of Stripe prices you keep, and a branch in every later question about what a given customer gets.
Two cases are not worth that. A plan nobody has subscribed to yet is one, and it needs no ceremony at all: edit the draft, publish, done. The other is fixing wording that was never priced, like a plan description. Changing it does not change what anyone owes.
The case that looks like a third one is a price you are still testing. Version it properly or do not ship it. A version you publish and deprecate a week later leaves a handful of customers pinned to it for as long as they keep paying, and that is a long time to support a price you already regret.
The moment a change is worth a version is the moment someone would be annoyed to be moved off what they have. That is a commercial judgement rather than a technical one, and it is the right way round.
What the customer sees
Nothing. That is the point, and it is worth saying out loud because it makes this work hard to demo. A successful pricing change looks exactly like no pricing change to every existing customer: same invoice, same features, same quotas, same credit balance. The only people who see the new numbers are the ones who have not bought yet, plus anyone who chooses to switch, at which point proration decides what they are billed today.
I have not run this exact sequence end to end as one app. The API shapes match the documented SDK surface and our own constants rather than being variants written for this post, and the server behaviour described is what the billing service does today, read from the routes, not what it should do.
npm i @buildbase/sdk