How to add a free trial without a card in Next.js
A free trial with no credit card in Next.js: turn the card requirement off on the plan version, then handle the part Stripe cannot - what your app renders, and who the gate lets through.
In short
A card-less trial is one setting and one real decision. Turning off the card requirement on a plan version is configuration. The decision is the gate: a trialing subscription is not active, so code that checks for 'active' locks out every trial user you have.
Dropping the credit card from your trial does not remove billing work. It moves it into your app.
With a card on file, conversion is something Stripe does to the customer while you sleep. Take the card away and that machinery is gone. Nobody is charged automatically on day 8, which means the only things standing between a trial and a paying customer are the emails you send and the wall you put up. Both are yours to build. Most guides on this stop at the checkout flag, and the flag is the easy half.
Before you start
A BuildBase project with the billing module connected to your own Stripe account, and at least one published plan version. Billing needs Stripe credentials, a plan, a published version and a pricing group configured in the dashboard before any of the SDK components return data.
Step one: turn the card requirement off
Trials are configured per plan version, not globally. Each version carries a trial block with three fields: whether it is enabled, how many days it runs, and whether it collects a card.
// The plan version's trial config, set in the dashboard
trial: {
enabled: true,
durationDays: 7,
requireCard: false,
}That last field is the whole feature, and it defaults to true. Card-less is
something you opt into on a specific plan version, not a platform default, so
flipping it is a deliberate pricing decision rather than a checkbox you forgot.
Per version also means you can still change it after that version is published:
the freeze on a published version covers
pricing, features, limits and quotas, and the trial block sits outside it. Set
durationDays deliberately too: the schema default is 14, and the 7 above is
our own choice rather than the number you inherit.
With requireCard off, the checkout session is built with
payment_method_collection: 'if_required', whose own default is always. Both
knobs point the same way until you turn them. Read if_required literally,
though: it skips the card only when the total due for the session comes to zero,
and a trial is what makes that true. Add a setup fee and the card form is back.
In the plain case checkout completes with nothing entered, and the subscription
opens in trialing rather than active.
Our own trial runs on exactly this: 7 days, no card required. The "no credit
card required" line on our signup page is derived from PLAN_FACTS, our mirror
of the published plan terms, rather than typed out next to the button - so it
removes itself the day that flag flips. Worth copying. A trust badge nobody owns
is a trust badge that eventually lies.
Step two: know what the trial's end already does
Cancellation is already handled for you. The same checkout session that skips the card also carries an end behavior:
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel',
},
}cancel is one of three values that field takes. The other two are pause,
which freezes the subscription and stops it invoicing until a card arrives, and
create_invoice, which bills anyway and hands the problem to your dunning
settings. We chose cancel. It is the honest default for a trial nobody paid
for, but it is a choice, and the other two are there if your product would
rather hold the account open.
So the subscription does end. What it does not do is decide anything about your product. Stripe can cancel or pause a subscription; it cannot grey out your dashboard or pick which message the user reads on day 8. So what does that user actually see? Stripe hands that part back to you, and it is the rest of this guide.
Step three: render the three states
A trial is not a boolean. It has three states worth drawing differently, and
useTrialStatus returns all three.
import { useTrialStatus, WhenTrialEnding } from '@buildbase/sdk/react';
function TrialBanner() {
const { isTrialing, daysRemaining, isTrialEnding } = useTrialStatus();
if (!isTrialing) return null;
return (
<WhenTrialEnding daysThreshold={3}>
<Banner tone="warning">
{daysRemaining <= 0
? 'Your trial ends today'
: `Your trial ends in ${daysRemaining} ${daysRemaining === 1 ? 'day' : 'days'}`}
</Banner>
</WhenTrialEnding>
);
}| Return | What it is |
|---|---|
isTrialing | In the trial period |
daysRemaining | Days left, which reaches zero before it ends |
isTrialEnding | Inside the warning window |
The ternary on daysRemaining is not defensive padding. Our console renders
this same banner and needs that branch, because "your trial ends in 0 days" is
what you ship without it.
One ordering note, learned the annoying way. A payment problem outranks a trial
warning, so check past_due and unpaid before you check the trial. A user
whose card just failed does not need to hear about a countdown.
Step four: get the gate right
A trialing subscription is not active. trialing is a status of its own. The
obvious check locks out every trial user you have. Quietly, and at your own
expense:
// Wrong. Your trial users are not 'active'.
const { subscription } = await bb.subscription.get(workspaceId);
if (!subscription || subscription.subscriptionStatus !== 'active') {
return Response.json({ error: 'Subscription required' }, { status: 403 });
}I am not holding up a strawman. That is the server-side snippet in our own billing docs, and it is fine for the case it was written for - a paid subscription, no trial in the picture. Add a trial and it is a lockout. Ours is the kind of example that gets pasted, so treat it as the default you have to override rather than a mistake nobody makes.
Two statuses beyond active should get through:
const LIVE_STATUSES = ['active', 'trialing', 'past_due'];
const { subscription } = await bb.subscription.get(workspaceId);
const status = subscription?.subscriptionStatus?.toLowerCase();
if (!status || !LIVE_STATUSES.includes(status)) {
return Response.json({ error: 'Subscription required' }, { status: 403 });
}past_due belongs on that list. A failed charge starts Stripe's retry cycle,
and cutting access off on the first failure is hostile when the usual cause is
an expired card.
Now the half that matters because you skipped the card. A status check alone
trusts trialing forever. Normally an ended trial is moved out of trialing by
webhook, so the status is authoritative - but a card-less trial is the case
where that is least reliable. There is no payment to fail, nothing to
retry, and a webhook that never lands leaves the subscription sitting in
trialing indefinitely. A free pass with no expiry.
So check the date too. Read it off the subscription object as trialEnd, not
from the React hook, which cannot run inside a route handler:
const trialExpired =
status === 'trialing' &&
subscription?.trialEnd &&
Date.parse(subscription.trialEnd) < Date.now();
if (!status || trialExpired || !LIVE_STATUSES.includes(status)) {
return Response.json({ error: 'Subscription required' }, { status: 403 });
}One deliberate asymmetry: an absent or unparseable trialEnd should be treated
as not expired. Trusting the status beats locking a paying customer out over a
date you could not read. That is how our own gate behaves, and it is pinned by a
test rather than left to whoever edits it next.
Key takeaway
Check the status against all three live values, then check trialEnd
separately. status === 'active' is wrong twice over: it excludes your trial
users, and it trusts a trialing that a missing webhook can leave stuck.
Step five: send the emails, because nothing else converts
With a card on file you could skip this and still get paid. Without one, these emails are the conversion mechanism, not a courtesy.
Three events fire across a trial's life: subscription.trial_started,
subscription.trial_will_end and subscription.trial_expired. The middle one
earns its keep. Subscribe a workflow or a webhook to it and send the
trial-ending template, which takes WorkspaceName and DaysRemaining.
Note what that template keys on. Workspace, not user. trialUsedAt is stamped
on the workspace document the first time a trial starts, and checkout skips the
trial when that field is already set or the workspace already has a Stripe
subscription. One workspace, one trial, however many people you invite.
Build a trial per user instead and you have built a renewable free plan: invite a colleague, get another 7 days. That invite is worth gating properly anyway, for reasons the seat guide covers. If a per-account allowance is what you actually want, that is a quota, not a trial, and it is a different module.
The failure modes
Gating on status === 'active'. Covered above. It is the expensive one,
because the users it breaks are the ones you have not sold yet, and they will
not file a bug - they will leave.
Trusting trialing indefinitely. Without the trialEnd check, a webhook
that never arrived is an indefinite free pass. This is likelier on a card-less
trial than on a normal one.
Printing daysRemaining raw. It hits zero while the trial is still running.
Branch on it or ship "ends in 0 days".
Letting the trial banner outrank a payment failure. Two banners can be true at once. Decide the priority yourself, or the user gets a countdown when what they needed was "your card was declined".
Re-trialing on plan change. Checkout skips the trial when the workspace already holds a Stripe subscription, so an upgrade does not restart the clock. If you hand-roll this instead, that is the case you will miss. Changing plan during the trial does the other thing worth knowing: it ends the trial on the spot rather than at the end of the window.
Our own plans are the shape this guide assumes: no free tier, Launch at $49 a
month, and a 7-day trial that does not ask for a card. Those figures come from
PLAN_FACTS, last verified 9 September 2026. If you are still deciding where
the entitlement check itself belongs, that argument comes
first. This guide picks up after it, on
who the gate admits.
A note on sourcing. Every Stripe parameter named in this post, the FAQs below
included, is quoted from what our own checkout code sends and then checked
against the API schema Stripe publishes in stripe-node, read on 12 September
2026: payment_method_collection takes always or if_required and defaults
to always, and
trial_settings.end_behavior.missing_payment_method takes cancel,
create_invoice or pause. The claim this guide turns on is Stripe's wording
rather than our inference - a subscription in a trial period is trialing, and
moves to active when the trial is over. Everything about our own behavior comes
from PLAN_FACTS, the plan version schema and the subscription gate in
packages/shared.
I have not run these snippets end to end as one app. Each matches the canonical SDK usage in our own docs and constants rather than a variant written for this post.
npm i @buildbase/sdk