DEV Community

Cover image for How to Add Free Trials to Your SaaS Without Friction: A Step-by-Step Guide With Kinde
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Add Free Trials to Your SaaS Without Friction: A Step-by-Step Guide With Kinde

Most SaaS products need free trials. Most free trial implementations are a mess — a trial_expires_at column in the database, a cron job nobody fully trusts, middleware that sometimes runs and sometimes does not, and an upgrade prompt that appears on the wrong page at the wrong moment.

In this article, you will learn:

  • Why most free trial implementations break down at the edges
  • How Kinde's billing model works today and the honest state of native trial support
  • How to use a $0.00 Free plan as a friction-free trial container — no credit card required
  • How to stamp a trial_start date onto every new user using Kinde Workflows
  • How to surface that date as a custom claim in the access token so your app never queries a database to check trial status
  • How to build a useTrialStatus hook that drives every trial-aware UI decision in one place
  • How to build the upgrade prompt that appears at day 7, day 13, and on expiry
  • How to transition the user to a paid plan the moment the trial ends — smoothly, with no data loss
  • How to test the full trial lifecycle without waiting 14 days

Let's dive in!

The Free Trial Problem Nobody Talks About

Free trials are not hard to implement. They are hard to implement correctly. The obvious approach — store a trial end date in your database and check it on every request — works until it does not. The cron job that downgrades expired trial users runs at 3am but the user is in a different timezone. The middleware check runs on API routes but not on the WebSocket connection. The upgrade prompt fires but the user already paid ten minutes ago and the webhook has not processed yet.

The underlying issue is that trial state lives in multiple places — your database, Stripe, your app's middleware — and those places are never perfectly in sync.

The cleaner approach keeps trial state in one place: the Kinde access token. Every time the user makes a request, the token carries their trial_start date and their current plan. Your app reads those claims, computes whether the trial is active, expired, or converted, and acts accordingly. No database query. No sync lag. No cron jobs.

Here is how to build that.

LEFT

How Kinde Billing Works Today — And One Important Caveat

Before writing a single line of code, one thing needs to be stated clearly: as of mid-2026, Kinde Billing does not have native free trial support built in. The Kinde docs list "support for plan models with free trial periods" as a known limitation that is actively being worked on.

This article does not work around that limitation — it builds a production-grade trial system using the Kinde primitives that exist today: a $0.00 Free plan, custom user properties, Kinde Workflows, and feature flags. The result is a trial system that is arguably more flexible than a built-in trial toggle would be, because you control the logic entirely.

When Kinde ships native trial support, the trial_start workflow and the expiry logic in your app can be replaced with a single plan configuration. The feature-gating code using getBooleanFlag stays exactly the same. The migration will be clean.

Note: everything in this article uses Kinde's non-production environment for setup and Stripe test mode for payments. Do not touch production until you have walked the full flow in test mode.

The Architecture You Are Building

The trial system has four moving parts:

1. A Free plan configured in Kinde Billing with a $0.00 charge, credit card collection disabled, and the trial feature flags attached to it. This is what users land on when they sign up.

2. A Workflow that fires on user post-authentication and stamps a trial_start Unix timestamp onto the user's Kinde property record the first time they authenticate.

3. Token customization that surfaces the trial_start property and the user's current plan flags in the access token, so your app can read trial state without a database lookup.

4. A useTrialStatus hook in your Next.js app that reads those token claims, computes the trial state, and drives every trial-aware UI decision — the countdown banner, the upgrade prompt, the locked feature overlay, and the expired paywall.

Four-part architecture showing Free plan (Kinde Billing) → Workflow stamps trial_start → Token carries trial_start + plan flags → useTrialStatus hook drives UI. Linear left-to-right flow with each component labeled and color coded

Step #1: Create the Free Plan in Kinde Billing

Navigate to Billing → Plans → Add plan.

Kinde Billing > Plans page showing the

Configure the plan with these settings:

  • Name: Free Trial (this is what users see on the pricing table)
  • Description: 14 days of full Pro access, no credit card required
  • Key: free_trial (this is how you reference it in code — cannot be changed after publishing)
  • Plan type: Users (for B2C) or Organizations (for B2B)

After saving the basic details, add a charge. Every plan in Kinde — including free plans — needs at least one charge so it syncs to Stripe correctly.

Select Add charge. Configure it as:

  • Name: Base subscription fee
  • Amount: $0.00
  • Interval: Monthly

Select Save.

Now configure the credit card collection setting. Because this is a free trial plan with a $0.00 charge, Kinde allows you to disable credit card collection. Scroll to the Payment collection section and disable the credit card requirement.

Kinde plan settings showing the Payment collection section with the credit card requirement toggle disabled

Note: credit card collection can only be disabled for plans with no paid charges. The moment you add a charge above $0.00, Kinde requires card collection. Your Free Trial plan stays at $0.00 for this reason — users sign up without friction and you collect card details only when they upgrade.

Now attach the feature flags that control what trial users can access. Navigate to the Features section of the plan and attach the flags that represent your Pro tier features — things like advanced_analytics, api_access, export_data. These flags will activate automatically for any user on the Free Trial plan.

![Kinde plan features section showing three feature flags attached to the Free Trial plan: advanced_analytics (boolean, true), api_access (boolean, true), export_data (boolean, true). These represent the Pro features users get during the trial]](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uetjbd1rngmv91tvvu7d.png)

Select Publish to make the plan live. Published plans sync to Stripe automatically.

Terrific! The trial container plan exists. Now stamp the start date.

Step #2: Stamp the Trial Start Date With a Kinde Workflow

Kinde Workflows let you run custom code in response to Kinde events. The trigger you need is user:post_authentication — it fires after a user authenticates and before the token is issued, giving you a window to set properties on the user record.

First, create the custom property that will hold the trial start date. Navigate to Settings → Data management → Properties → Add property.

Kinde Settings > Data management > Properties page showing the

Configure the property:

  • Name: Trial start date
  • Key: trial_start (cannot be changed later)
  • Type: Single line text (you will store a Unix timestamp as a string)
  • Private: off — this must be public so you can include it in the access token

Select Save.

Now create the workflow. Kinde Workflows live in your code repository and are synced to Kinde via GitHub. The recommended way to get started is to use the official Kinde workflow base template, which has the correct folder structure already set up.

Navigate to github.com/kinde-starter-kits/workflow-base-template, click the green Use this template button, and select Create a new repository. This creates a repo with the following structure:

kindeSrc/
└── environment/
    └── workflows/
        └── postUserAuthentication/
            └── Workflow.ts
Enter fullscreen mode Exit fullscreen mode

Replace the contents of postUserAuthentication/Workflow.ts with the trial start workflow:

import {
  onPostAuthenticationEvent,
  WorkflowSettings,
  WorkflowTrigger,
  createKindeAPI,
} from "@kinde/infrastructure";

export const workflowSettings: WorkflowSettings = {
  id: "trialStartWorkflow",
  name: "Stamp trial start date",
  failurePolicy: { action: "continue" },
  bindings: {
    "kinde.fetch": {},
  },
  trigger: WorkflowTrigger.PostAuthentication,
};

export default async function Workflow(event: onPostAuthenticationEvent) {
  // Only stamp the trial start on new users — never overwrite existing value
  if (event.context.isExistingUser) {
    return;
  }

  const userId = event.context.user.id;
  const trialStart = Math.floor(Date.now() / 1000).toString();

  // Use the Kinde Management API to set the trial_start property on the user
  const kindeAPI = await createKindeAPI(event);

  await kindeAPI.patch({
    endpoint: `user`,
    params: { id: userId },
    requestBody: {
      properties: {
        trial_start: trialStart,
      },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Commit and push the file to your repository. Then in your Kinde dashboard, navigate to Settings → Git repo, connect your repository, select your branch, and hit Sync code. Once the sync succeeds, navigate to Workflows — the "Stamp trial start date" workflow will appear with status Live.

Note: the isExistingUser check is critical. Without it, the workflow would overwrite trial_start on every login, resetting the trial clock each time the user authenticates. The check ensures the timestamp is only written once — on first sign-in.

Kinde dashboard showing the Workflows section with the

Note: the isExistingUser check is critical. Without it, the workflow would overwrite the trial_start on every login, resetting the trial clock each time the user authenticates. The check ensures the timestamp is only written once — on first sign-in.

Step #3: Surface Trial State in the Access Token

The trial_start property is now stored on the user record in Kinde. To make it available in the access token without a database query, add it to the token customization settings for your application.

Navigate to Settings → Applications → [your app] → Tokens → Customize on the Access token card.

Kinde application token customization dialog showing the Properties section with

In the Customize access token dialog, scroll to the Properties section and toggle on trial_start. Select Save.

From this point, every access token issued to your users will contain an application_properties claim with the trial_start value:

{
  "sub": "kp_abc123",
  "email": "user@example.com",
  "feature_flags": {
    "advanced_analytics": { "t": "b", "v": true },
    "api_access": { "t": "b", "v": true },
    "export_data": { "t": "b", "v": true }
  },
  "application_properties": {
    "trial_start": { "v": "1717200000" }
  },
  "iss": "https://yourapp.kinde.com",
  "exp": 1717286400
}
Enter fullscreen mode Exit fullscreen mode

The feature_flags claim carries the Pro features active on the Free Trial plan. The application_properties.trial_start.v claim carries the Unix timestamp of when the trial started. Your app now has everything it needs to compute trial status without touching the database.

Amazing!

Step #4: Build the useTrialStatus Hook

This hook is the single source of truth for trial state in your React app. Every trial-aware component — the countdown banner, the upgrade prompt, the feature gate, the expired paywall — reads from this hook.

// hooks/useTrialStatus.ts
"use client";

import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";

const TRIAL_LENGTH_DAYS = 14;
const TRIAL_LENGTH_SECONDS = TRIAL_LENGTH_DAYS * 24 * 60 * 60;

export type TrialStatus =
  | "active"      // Trial is running, days remain
  | "expiring"    // Trial has 3 or fewer days left
  | "expired"     // Trial ended, user has not upgraded
  | "converted";  // User is on a paid plan

export interface TrialState {
  status: TrialStatus;
  daysRemaining: number;
  trialStartDate: Date | null;
  trialEndDate: Date | null;
  isPro: boolean;
}

export function useTrialStatus(): TrialState {
  const { getClaim, getBooleanFlag } = useKindeBrowserClient();

  // Read trial_start from the application_properties claim
  // The value is a Unix timestamp stored as a string
  const trialStartClaim = getClaim("application_properties", "access_token");
  const trialStartRaw = (trialStartClaim?.value as any)?.trial_start?.v;
  const trialStartSeconds = trialStartRaw ? parseInt(trialStartRaw, 10) : null;

  // Read feature flags to determine if the user has Pro access
  // These flags are active on both the Free Trial plan and the paid Pro plan
  const hasAdvancedAnalytics = getBooleanFlag("advanced_analytics", false);
  const hasApiAccess = getBooleanFlag("api_access", false);

  // A user is "Pro" if they have the paid plan flags active
  // When the Free Trial expires, these flags go false unless they upgrade
  // Note: during the trial, these are also true — so use trial state to distinguish
  const hasPlanAccess = hasAdvancedAnalytics && hasApiAccess;

  if (!trialStartSeconds) {
    // No trial_start means this is a legacy user or the workflow has not run yet
    // Treat as expired to avoid unintended access
    return {
      status: "expired",
      daysRemaining: 0,
      trialStartDate: null,
      trialEndDate: null,
      isPro: hasPlanAccess,
    };
  }

  const now = Math.floor(Date.now() / 1000);
  const trialEndSeconds = trialStartSeconds + TRIAL_LENGTH_SECONDS;
  const secondsRemaining = trialEndSeconds - now;
  const daysRemaining = Math.max(0, Math.ceil(secondsRemaining / 86400));

  const trialStartDate = new Date(trialStartSeconds * 1000);
  const trialEndDate = new Date(trialEndSeconds * 1000);

  // If the user has Pro feature flags active AND the trial has expired,
  // they have upgraded — they are a paying customer
  if (hasPlanAccess && secondsRemaining <= 0) {
    return {
      status: "converted",
      daysRemaining: 0,
      trialStartDate,
      trialEndDate,
      isPro: true,
    };
  }

  if (secondsRemaining <= 0) {
    return {
      status: "expired",
      daysRemaining: 0,
      trialStartDate,
      trialEndDate,
      isPro: false,
    };
  }

  if (daysRemaining <= 3) {
    return {
      status: "expiring",
      daysRemaining,
      trialStartDate,
      trialEndDate,
      isPro: true,
    };
  }

  return {
    status: "active",
    daysRemaining,
    trialStartDate,
    trialEndDate,
    isPro: true,
  };
}
Enter fullscreen mode Exit fullscreen mode

Note: the hook reads application_properties from the access token. The Kinde browser client's getClaim method defaults to reading from the access token, which is what you want here. The getBooleanFlag method also reads from the token — no network request is made.

Step #5: Build the Trial UI Components

With the useTrialStatus hook in place, build the UI components that surface trial state to users.

The Trial Banner

This sits at the top of the dashboard and counts down the remaining days. It changes color and urgency as the trial approaches expiry.

// components/TrialBanner.tsx
"use client";

import { useTrialStatus } from "@/hooks/useTrialStatus";
import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";

export function TrialBanner() {
  const { status, daysRemaining } = useTrialStatus();

  // Do not show the banner for converted users or when there is no trial data
  if (status === "converted" || status === "expired") return null;

  const isUrgent = status === "expiring";

  return (
    <div
      className={`w-full px-4 py-2 text-sm font-medium text-center flex items-center justify-center gap-4 ${
        isUrgent
          ? "bg-red-50 text-red-700 border-b border-red-200"
          : "bg-blue-50 text-blue-700 border-b border-blue-200"
      }`}
    >
      <span>
        {isUrgent
          ? `Your trial expires in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}. Upgrade to keep your access.`
          : `${daysRemaining} days left in your free trial.`}
      </span>
      <RegisterLink
        planInterest="pro_monthly"
        className={`px-3 py-1 rounded text-xs font-semibold ${
          isUrgent
            ? "bg-red-600 text-white hover:bg-red-700"
            : "bg-blue-600 text-white hover:bg-blue-700"
        }`}
      >
        Upgrade now
      </RegisterLink>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Expired Paywall

When the trial ends, replace the dashboard content with an upgrade prompt. Users can still access their data — they just cannot take action until they upgrade.

// components/TrialExpiredPaywall.tsx
"use client";

import { useTrialStatus } from "@/hooks/useTrialStatus";
import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";

interface TrialExpiredPaywallProps {
  children: React.ReactNode;
}

export function TrialExpiredPaywall({ children }: TrialExpiredPaywallProps) {
  const { status, trialEndDate } = useTrialStatus();

  if (status !== "expired") {
    // Trial is active or user has converted — show the content normally
    return <>{children}</>;
  }

  const endDateFormatted = trialEndDate
    ? trialEndDate.toLocaleDateString("en-US", {
        month: "long",
        day: "numeric",
        year: "numeric",
      })
    : "recently";

  return (
    <div className="relative">
      {/* Blur the underlying content so users can see what they are missing */}
      <div className="pointer-events-none select-none blur-sm opacity-40">
        {children}
      </div>

      {/* Paywall overlay */}
      <div className="absolute inset-0 flex items-center justify-center">
        <div className="bg-white rounded-xl shadow-xl border border-gray-200 p-8 max-w-md w-full mx-4 text-center space-y-4">
          <div className="text-2xl font-bold text-gray-900">
            Your trial ended on {endDateFormatted}
          </div>
          <p className="text-gray-500 text-sm">
            Your data is safe. Upgrade to Pro to get back to work.
          </p>

          <RegisterLink
            planInterest="pro_monthly"
            className="block w-full py-3 px-6 bg-black text-white rounded-lg font-semibold text-sm hover:bg-gray-800 transition-colors"
          >
            Upgrade to Pro
          </RegisterLink>

          <p className="text-xs text-gray-400">
            No setup required. Your account picks up exactly where you left off.
          </p>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Feature Gate

For features that are available during the trial but locked on a free-forever tier, use this gate to show an upgrade prompt in context rather than a full-page paywall.

// components/FeatureGate.tsx
"use client";

import { useTrialStatus } from "@/hooks/useTrialStatus";
import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";

interface FeatureGateProps {
  feature: string;
  children: React.ReactNode;
}

export function FeatureGate({ feature, children }: FeatureGateProps) {
  const { isPro, status } = useTrialStatus();

  if (isPro) {
    return <>{children}</>;
  }

  return (
    <div className="relative rounded-lg border border-gray-200 p-6">
      <div className="pointer-events-none select-none blur-sm opacity-40">
        {children}
      </div>
      <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-white/80">
        <div className="text-center space-y-2 px-4">
          <p className="text-sm font-semibold text-gray-900">
            {feature} is a Pro feature
          </p>
          {status === "expired" ? (
            <RegisterLink
              planInterest="pro_monthly"
              className="inline-block px-4 py-2 bg-black text-white rounded-md text-xs font-medium"
            >
              Upgrade to unlock
            </RegisterLink>
          ) : (
            <p className="text-xs text-gray-500">
              Available during your trial — upgrade to keep access after it ends.
            </p>
          )}
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Putting the Components Together

Use all three in your dashboard layout:

// app/dashboard/layout.tsx
import { TrialBanner } from "@/components/TrialBanner";
import { TrialExpiredPaywall } from "@/components/TrialExpiredPaywall";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex flex-col">
      {/* Trial countdown banner — hidden when converted or expired */}
      <TrialBanner />

      <main className="flex-1">
        {/* Paywall wraps all dashboard content — activates only on expiry */}
        <TrialExpiredPaywall>{children}</TrialExpiredPaywall>
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

For individual features, wrap the component with FeatureGate:

// app/dashboard/analytics/page.tsx
import { FeatureGate } from "@/components/FeatureGate";
import { AdvancedAnalyticsDashboard } from "@/components/AdvancedAnalyticsDashboard";

export default function AnalyticsPage() {
  return (
    <div className="p-6">
      <h1 className="text-xl font-semibold mb-4">Analytics</h1>
      <FeatureGate feature="Advanced Analytics">
        <AdvancedAnalyticsDashboard />
      </FeatureGate>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dashboard showing the TrialBanner at the top with

The same dashboard with the trial expired — content blurred behind the TrialExpiredPaywall overlay showing

Step #6: Handle the Upgrade — Moving From Trial to Paid

When a trial user clicks "Upgrade to Pro," they go through Kinde's hosted payment flow via the RegisterLink component with planInterest="pro_monthly". After payment, Kinde switches the user's plan from free_trial to pro_monthly.

Two things happen in your app automatically:

First, the feature flags attached to the Pro plan remain active. The advanced_analytics, api_access, and export_data flags do not disappear — they continue to resolve as true because they are also attached to the Pro plan. The user notices no change in what they can access.

Second, the useTrialStatus hook detects that the trial has ended but the plan flags are still active, and returns status: "converted". The paywall never appears. The banner disappears.

On the server side, Kinde fires a webhook when the subscription is created. Use this to record the conversion in your own database if needed:

// app/api/webhooks/kinde/route.ts
import { getKindeWebhookDecodedEvent } from "@kinde/webhooks";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  let event;
  try {
    event = await getKindeWebhookDecodedEvent(request);
  } catch {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (event.type === "subscription.created") {
    const { user_id, plan_key } = event.data as {
      user_id: string;
      plan_key: string;
    };

    // Record the conversion — trial user is now a paid customer
    await db.users.update({
      where: { kindeId: user_id },
      data: {
        plan: plan_key,
        convertedAt: new Date(),
      },
    });

    console.log(`Trial converted: user ${user_id} upgraded to ${plan_key}`);
  }

  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

Note: the webhook is for your own record-keeping. The access control in your app should always read from the token — not from your database's plan column — because the token is always authoritative. The database record is useful for analytics, billing history, and support tooling.

Step #7: Test the Full Trial Lifecycle Without Waiting 14 Days

Testing a 14-day trial by waiting 14 days is not practical. Here are the three approaches for simulating trial state in development.

Approach 1: Manipulate the Trial Start Date

The quickest way to simulate an expiring or expired trial is to set the trial_start property to a past timestamp via the Kinde Management API. Create a test utility:

// scripts/set-trial-date.ts
// Usage: npx ts-node scripts/set-trial-date.ts <userId> <daysAgo>
// Example: npx ts-node scripts/set-trial-date.ts kp_abc123 13
//          Sets trial_start to 13 days ago — 1 day until expiry

const userId = process.argv[2];
const daysAgo = parseInt(process.argv[3] || "0");

const trialStart = Math.floor(Date.now() / 1000) - daysAgo * 86400;

async function setTrialDate() {
  const tokenResponse = await fetch(
    `https://${process.env.KINDE_DOMAIN}/oauth2/token`,
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "client_credentials",
        client_id: process.env.KINDE_M2M_CLIENT_ID!,
        client_secret: process.env.KINDE_M2M_CLIENT_SECRET!,
        audience: `https://${process.env.KINDE_DOMAIN}/api`,
      }),
    }
  );

  const { access_token } = await tokenResponse.json();

  const response = await fetch(
    `https://${process.env.KINDE_DOMAIN}/api/v1/user?id=${userId}`,
    {
      method: "PATCH",
      headers: {
        Authorization: `Bearer ${access_token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        properties: {
          trial_start: trialStart.toString(),
        },
      }),
    }
  );

  if (response.ok) {
    console.log(
      `Set trial_start to ${daysAgo} days ago for user ${userId}`
    );
    console.log(`Trial expires in ${14 - daysAgo} days`);
  }
}

setTrialDate();
Enter fullscreen mode Exit fullscreen mode

Run it to simulate each trial state:

# Active trial — 10 days remaining
npx ts-node scripts/set-trial-date.ts kp_abc123 4

# Expiring trial — 2 days remaining
npx ts-node scripts/set-trial-date.ts kp_abc123 12

# Expired trial
npx ts-node scripts/set-trial-date.ts kp_abc123 15
Enter fullscreen mode Exit fullscreen mode

After running the script, the user needs to re-authenticate to get a fresh token with the updated trial_start value.

Approach 2: Override Trial Length in the Hook

For rapid UI development, add a TRIAL_OVERRIDE_DAYS environment variable that overrides the trial length:

// In useTrialStatus.ts, replace the constant with:
const TRIAL_LENGTH_DAYS = process.env.NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS
  ? parseInt(process.env.NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS)
  : 14;
Enter fullscreen mode Exit fullscreen mode

Set NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS=1 in .env.local during development. A trial that started today will expire tomorrow, letting you test the full lifecycle in 24 hours.

Approach 3: Use the Trial Status Storybook

For component-level testing, pass mock trial state directly to the components by extracting the hook logic into a context provider that can be overridden in tests:

// This pattern lets you render <TrialBanner /> with any trial state
// in Storybook or Jest without needing a real Kinde token
const mockTrialState: TrialState = {
  status: "expiring",
  daysRemaining: 2,
  trialStartDate: new Date(Date.now() - 12 * 86400 * 1000),
  trialEndDate: new Date(Date.now() + 2 * 86400 * 1000),
  isPro: true,
};
Enter fullscreen mode Exit fullscreen mode

Wonderful! The full trial lifecycle — from first sign-up through expiry and upgrade — is now testable without waiting.

Putting It All Together

Here is the complete trial system mapped across all layers:

Layer What it does Where it lives
Free Trial plan (Kinde) $0.00 plan, no credit card, Pro feature flags attached Kinde Billing dashboard
Workflow Stamps trial_start Unix timestamp on first sign-in trialStartWorkflow.ts pushed to Kinde
Token customization Surfaces trial_start and feature flags in the access token Kinde application settings
useTrialStatus hook Reads token claims, computes trial state hooks/useTrialStatus.ts
TrialBanner Shows countdown, changes urgency at day 11 components/TrialBanner.tsx
TrialExpiredPaywall Blurs content, shows upgrade prompt on expiry components/TrialExpiredPaywall.tsx
FeatureGate Locks individual features after expiry components/FeatureGate.tsx
Webhook handler Records conversion in your database app/api/webhooks/kinde/route.ts

Full system showing a user signing up → hitting the Free Trial plan → Workflow stamps trial_start → token carries trial_start + feature flags → useTrialStatus computes state → three UI outcomes: active (banner + full access), expiring (urgent banner), expired (paywall)

What Happens When Kinde Adds Native Trial Support

When Kinde ships built-in trial periods — which is on their roadmap — the migration from this approach is straightforward:

The trialStartWorkflow.ts gets retired. The trial length configuration moves from your app's constant into the Kinde plan settings. Kinde handles the start date stamping automatically.

The useTrialStatus hook gets simplified — instead of reading application_properties.trial_start, it reads a Kinde-provided trial claim directly from the token. The state logic (active, expiring, expired, converted) stays identical.

The TrialBanner, TrialExpiredPaywall, FeatureGate, and webhook handler stay completely unchanged.

The migration is a one-afternoon job, not a rewrite.

Conclusion

In this article, you built a complete free trial system using Kinde's existing billing primitives: a $0.00 Free Trial plan with Pro feature flags, a Workflow that stamps the trial start date on first sign-in, token customization that surfaces that date without a database query, and a useTrialStatus hook that drives every trial-aware UI decision from one place.

The result is a trial system with no cron jobs, no database sync issues, no middleware that sometimes runs and sometimes does not. Trial state lives in the token. Your app reads it. The user sees the right thing at the right moment.

Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account at kinde.com and have your trial system running before lunch.

Top comments (0)