You offer a 14-day free trial. The user signs up, forgets about your product by day three, and on day 15 Stripe charges their card for the first time. They dispute the charge and you lose the customer.
The trial window is not just a billing mechanic -- it is the time you have to prove your product's value. Get the engineering right and you can focus on that. Miss the reminders, forget the downgrade path, or let users access paid features after the trial ends, and you burn credibility before the relationship starts.
This post covers the full trial flow: checkout with a trial period, tracking status in Drizzle ORM, a reminder email three days before expiry, and a graceful downgrade on the webhook.
Add two columns to your subscriptions table:
// modules/subscription/subscription.schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const subscriptionTable = pgTable('subscriptions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
stripeSubId: text('stripe_sub_id').notNull().unique(),
status: text('status').notNull(), // trialing | active | canceled | past_due
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
Run npm run db:generate && npm run db:migrate after the schema change.
When creating the Stripe Checkout session, pass subscription_data.trial_period_days:
// modules/subscription/subscription.service.ts
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: user.email,
line_items: [{ price: process.env.NEXT_PUBLIC_STRIPE_PRICE_SUBSCRIPTION, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
metadata: { userId: user.id },
},
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
});
Stripe will not charge the card until the trial ends. Requiring a card upfront is the right call -- it reduces churn at conversion because the charge happens without any extra friction from the user.
Your customer.subscription.created and customer.subscription.updated handlers need to sync status and trialEndsAt:
// app/api/webhooks/stripe/route.ts (inside the switch)
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
await subscriptionService.upsert({
stripeSubId: sub.id,
status: sub.status,
trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
});
break;
}
When the trial converts, Stripe fires customer.subscription.updated with status: 'active' and trial_end: null. The same upsert clears the trial state with no extra code.
In your service layer, treat trialing the same as active for feature access:
// lib/auth.ts
export function hasAccess(status: string): boolean {
return status === 'active' || status === 'trialing';
}
Trialing users get the full product. When they convert, nothing changes. When they do not, you downgrade them on the next webhook event.
Three days before expiry, send an email: "Your trial ends Friday -- add a card to keep access."
Wire a Vercel cron job to run daily and find subscriptions where trial_ends_at falls within the next three days:
// modules/subscription/subscription.repo.ts
export async function findTrialsEndingSoon(db: Db, days: number) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + days);
return db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.status, 'trialing'),
lte(subscriptionTable.trialEndsAt, cutoff),
gt(subscriptionTable.trialEndsAt, new Date()),
),
);
}
Send a reminder email via Resend for each result. Add a reminderSentAt column if you need exactly-once delivery across cron runs.
Stripe fires customer.subscription.updated with status: 'past_due' or status: 'canceled' when the trial ends without a successful charge. Your webhook handler already syncs this. In your feature-gate helper, both statuses return false from hasAccess() -- access is revoked automatically.
No separate cron job needed. Stripe tells you when the trial is over.
If the user adds a card and Stripe retries successfully, it fires customer.subscription.updated with status: 'active'. The same upsert restores access immediately. From the user's perspective: they added a card, refreshed the page, and access came back. Clean.
A trial flow that actually converts:
status and trialEndsAt persisted in Drizzle ORM via webhookIf you are starting from scratch, Claude Code Boilerplates ships with Stripe, Drizzle ORM, Resend, and Vercel cron already wired. Clone it and you are writing trial logic -- not boilerplate setup -- from the first hour.