General
Billing
Paywall
Learn how to set up a paywall to restrict access to paid plans only.
By default, the starter kit includes a free plan, allowing users to access your application after signing up without payment. If you want to require a paid plan or trial, you can set up a paywall.
Setting Up a Paywall
To enable a paywall, remove or disable the free plan in your billing configuration:
config/billing.config.ts
export const billingConfig = {
plans: {
// Remove the free plan
// free: {
// isFree: true,
// },
pro: {
// ... pro plan configuration
}
}
};When the free plan is removed, users will be redirected to the plan selection page (/dashboard/choose-plan) after signup and onboarding.
Checking for Active Plans
To restrict access to features based on plan status, check if the user has an active plan:
Server-Side (tRPC)
trpc/routers/premium-feature.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { requirePaidPlan } from '@/lib/billing';
export const premiumFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
// This will throw if organization doesn't have a paid plan
await requirePaidPlan(ctx.organization.id);
// Allow access to premium feature
return { data: 'premium content' };
})
});Client-Side
components/premium-feature.tsx
'use client';
import Link from 'next/link';
import { trpc } from '@/trpc/client';
export function PremiumFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (
!billingStatus?.activePlan ||
billingStatus.activePlan.planId === 'free' ||
(billingStatus.subscription?.status !== 'active' &&
billingStatus.subscription?.status !== 'trialing')
) {
return (
<div>
<p>This feature requires an active subscription.</p>
<Link href="/dashboard/choose-plan">Upgrade now</Link>
</div>
);
}
return <div>Premium feature content</div>;
}Protecting Routes
You can protect entire routes based on plan status:
app/(saas)/dashboard/premium/page.tsx
import { getSession } from "@/lib/auth";
import { getActivePlanForOrganization } from "@/lib/billing";
import { redirect } from "next/navigation";
export default async function PremiumPage() {
const session = await getSession();
if (!session?.session.activeOrganizationId) {
redirect("/auth/sign-in");
}
const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId);
if (!activePlan || activePlan.planId === 'free') {
redirect("/dashboard/choose-plan");
}
return <div>Premium page content</div>;
}Plan-Specific Access
You can also check for specific plans:
lib/billing/check-access.ts
import { hasSpecificPlan } from '@/lib/billing';
export async function checkPlanAccess(
organizationId: string,
requiredPlanId: string
): Promise<boolean> {
return hasSpecificPlan(organizationId, requiredPlanId);
}components/pro-feature.tsx
'use client';
import Link from 'next/link';
import { trpc } from '@/trpc/client';
export function ProFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (billingStatus?.activePlan?.planId !== 'pro') {
return (
<div>
<p>This feature is only available on the Pro plan.</p>
<Link href="/dashboard/choose-plan">Upgrade to Pro</Link>
</div>
);
}
return <div>Pro feature content</div>;
}Organization-Based Paywalls
Billing is organization-based by default. Use the organization context:
trpc/routers/organization-feature.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { requirePaidPlan } from '@/lib/billing';
export const organizationFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
// This will throw if organization doesn't have a paid plan
await requirePaidPlan(ctx.organization.id);
return { data: 'organization premium content' };
})
});