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' };
  })
});