Skip to main content

How to Customize the T3 Stack for Production SaaS 2026

·StarterPick Team
t3-stackcustomizationnextjstrpcguide2026

TL;DR

T3 Stack is a structure, not a SaaS starter. It gives you TypeScript + Next.js + tRPC + Prisma + NextAuth — the skeleton. You build the Stripe integration, email system, landing page, and admin panel on top. This guide shows how to extend T3 Stack into a production SaaS product with the most common additions.


What T3 Stack Ships With

npm create t3-app@latest my-saas -- --noGit

# You get:
# ✓ Next.js (App Router)
# ✓ TypeScript (strict)
# ✓ tRPC (type-safe API)
# ✓ Prisma (ORM)
# ✓ NextAuth (auth)
# ✓ Tailwind CSS

What you must add:

  • Stripe billing
  • Email (Resend)
  • Landing page
  • Admin panel
  • shadcn/ui components (optional but recommended)
  • Error tracking (Sentry)
  • Analytics (PostHog)

Addition 1: Stripe Billing

// prisma/schema.prisma — add subscription fields
model User {
  id                String    @id @default(cuid())
  // ... existing fields
  stripeCustomerId  String?   @unique
  subscription      Subscription?
}

model Subscription {
  id                   String   @id @default(cuid())
  userId               String   @unique
  stripeSubscriptionId String   @unique
  status               String
  priceId              String
  currentPeriodEnd     DateTime
  cancelAtPeriodEnd    Boolean  @default(false)
  createdAt            DateTime @default(now())
  user                 User     @relation(fields: [userId], references: [id])
}
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// src/server/api/routers/billing.ts — tRPC router for billing
import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc';
import { z } from 'zod';
import { stripe } from '~/lib/stripe';

export const billingRouter = createTRPCRouter({
  createCheckoutSession: protectedProcedure
    .input(z.object({ priceId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const session = await stripe.checkout.sessions.create({
        customer_email: ctx.session.user.email!,
        line_items: [{ price: input.priceId, quantity: 1 }],
        mode: 'subscription',
        success_url: `${process.env.NEXTAUTH_URL}/dashboard?success=true`,
        cancel_url: `${process.env.NEXTAUTH_URL}/pricing`,
        metadata: { userId: ctx.session.user.id },
      });
      return { url: session.url! };
    }),

  getSubscription: protectedProcedure.query(async ({ ctx }) => {
    return ctx.db.subscription.findUnique({
      where: { userId: ctx.session.user.id },
    });
  }),

  createPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
    const user = await ctx.db.user.findUnique({
      where: { id: ctx.session.user.id },
    });
    const session = await stripe.billingPortal.sessions.create({
      customer: user!.stripeCustomerId!,
      return_url: `${process.env.NEXTAUTH_URL}/dashboard`,
    });
    return { url: session.url };
  }),
});

Addition 2: Email with Resend

npm install resend @react-email/components
// src/lib/email.ts
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY);

// src/server/api/routers/email.ts
export const emailRouter = createTRPCRouter({
  sendWelcome: protectedProcedure.mutation(async ({ ctx }) => {
    await resend.emails.send({
      from: 'welcome@yoursaas.com',
      to: ctx.session.user.email!,
      subject: 'Welcome!',
      html: `<p>Welcome, ${ctx.session.user.name}!</p>`,
    });
  }),
});
// Integrate with NextAuth callbacks for automatic welcome email
// src/server/auth.ts
events: {
  async signIn({ user, isNewUser }) {
    if (isNewUser) {
      await resend.emails.send({
        from: 'welcome@yoursaas.com',
        to: user.email!,
        subject: 'Welcome to YourSaaS!',
        html: '<p>Thanks for signing up!</p>',
      });
    }
  },
},

Addition 3: shadcn/ui Components

npx shadcn-ui@latest init
# Follow prompts: TypeScript, App Router, CSS variables

# Add components as needed
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add table
npx shadcn-ui@latest add badge
// src/components/dashboard/SubscriptionCard.tsx
import { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/card';
import { Badge } from '~/components/ui/badge';
import { Button } from '~/components/ui/button';
import { api } from '~/trpc/react';

export function SubscriptionCard() {
  const { data: subscription } = api.billing.getSubscription.useQuery();
  const createPortal = api.billing.createPortalSession.useMutation({
    onSuccess: ({ url }) => window.location.href = url,
  });

  return (
    <Card>
      <CardHeader>
        <CardTitle>Subscription</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="flex items-center justify-between">
          <div>
            <Badge variant={subscription?.status === 'active' ? 'default' : 'secondary'}>
              {subscription?.status ?? 'No subscription'}
            </Badge>
          </div>
          <Button
            onClick={() => createPortal.mutate()}
            variant="outline"
            size="sm"
          >
            Manage Billing
          </Button>
        </div>
      </CardContent>
    </Card>
  );
}

Addition 4: Route Protection Middleware

// src/middleware.ts
import { withAuth } from 'next-auth/middleware';

export default withAuth({
  pages: {
    signIn: '/auth/signin',
  },
});

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/settings/:path*',
    '/api/trpc/:path*',  // Protect all tRPC routes
  ],
};

Addition 5: Admin Panel (Simple)

// src/server/api/routers/admin.ts — admin router
import { createTRPCRouter, adminProcedure } from '~/server/api/trpc';

// adminProcedure extends protectedProcedure to check admin role
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
  if (ctx.session.user.role !== 'admin') {
    throw new TRPCError({ code: 'FORBIDDEN' });
  }
  return next({ ctx });
});

export const adminRouter = createTRPCRouter({
  getUsers: adminProcedure.query(async ({ ctx }) => {
    return ctx.db.user.findMany({
      include: { subscription: true },
      orderBy: { createdAt: 'desc' },
      take: 100,
    });
  }),

  getMetrics: adminProcedure.query(async ({ ctx }) => {
    const [totalUsers, activeSubscriptions] = await Promise.all([
      ctx.db.user.count(),
      ctx.db.subscription.count({ where: { status: 'active' } }),
    ]);
    return { totalUsers, activeSubscriptions };
  }),
});

The Complete T3 SaaS package.json Additions

{
  "dependencies": {
    "@react-email/components": "^0.0.16",
    "@tanstack/react-table": "^8.11",
    "class-variance-authority": "^0.7",
    "clsx": "^2.1",
    "lucide-react": "^0.344",
    "resend": "^3.2",
    "stripe": "^14.20",
    "tailwind-merge": "^2.2"
  }
}

With these additions, T3 Stack becomes a complete SaaS foundation with type safety from database to client — something no paid boilerplate provides in the same way.


Compare T3 Stack with other TypeScript-first boilerplates on StarterPick.

Check out this boilerplate

View T3 Stack on StarterPick →

Comments