Guides
Build guide

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.

Dharmendra Jagodana8 min read

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>
  );
}
ReturnWhat it is
isTrialingIn the trial period
daysRemainingDays left, which reaches zero before it ends
isTrialEndingInside 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
billing
trials
nextjs

Frequently Asked Questions

How do I start a trial without asking for a card?

Turn off the card requirement on the plan version. BuildBase then sends payment_method_collection: 'if_required' to Stripe Checkout, so the session completes with no payment method attached and the subscription opens in trialing.

Does a trialing subscription count as active?

No. 'trialing' is its own status, separate from 'active'. Three statuses should grant access - active, trialing and past_due - so check membership of that set rather than comparing the string to 'active'.

What happens when the trial ends and there is still no card?

The checkout session sets trial_settings.end_behavior.missing_payment_method to 'cancel', so Stripe cancels it. Your app only learns that by webhook, and a missed one leaves the status at trialing, so check trialEnd as well. What the user sees on day 8 is still yours to render.

Does the trial belong to the user or the workspace?

The workspace. trialUsedAt is a field on the workspace document and it is stamped once, so a workspace gets one trial no matter who signs in or how many people are invited.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime