Build a production-ready multi-tenant SaaS platform from scratch with subscriptions, team collaboration, and real-time features
TaskFlow Pro is a complete SaaS project management platform where teams can collaborate on tasks, track progress, and manage projects. This capstone project combines everything you've learned across all modules into one production-ready application.
Frontend
Next.js 15, TypeScript, Tailwind
Backend
Next.js API Routes, tRPC
Database
PostgreSQL, Prisma, Redis
Services
Stripe, AWS S3, SendGrid
A SaaS (Software as a Service) application is fundamentally different from a simple web app. It needs to handle multiple customers (tenants), each with their own data, users, and subscriptions, all while keeping everything secure and isolated.
Multi-tenancy means multiple customers (organizations) use the same application instance, but their data is completely isolated. Think of it like an apartment building - everyone shares the same building (application), but each apartment (tenant) is private and separate.
Organization/Workspace: The tenant entity. Each customer creates an organization.
Row-Level Security: Every database row has an organizationId to ensure data isolation.
Context Middleware: Automatically filters all queries by the current user's organization.
Subscription Tiers: Different plans with different feature limits and pricing.
Our database schema is designed around organizations. Every major entity (projects, tasks, files) belongs to an organization, ensuring complete data isolation between tenants.
Before building features, we need a solid foundation. This includes project setup, database schema, and the core multi-tenant architecture that everything else will build upon.
# Create Next.js project with TypeScript
npx create-next-app@latest taskflow-pro --typescript --tailwind --app
cd taskflow-pro
# Install core dependencies
npm install @prisma/client prisma
npm install next-auth @auth/prisma-adapter
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query
npm install zod
npm install stripe
npm install @aws-sdk/client-s3
npm install resend
npm install socket.io socket.io-client
npm install redis
# Install dev dependencies
npm install -D @types/node
npm install -D prismaCreate prisma/schema.prisma with our multi-tenant schema:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// User accounts
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
memberships OrganizationMember[]
createdTasks Task[] @relation("TaskCreator")
assignedTasks Task[] @relation("TaskAssignee")
}
// Organizations (Tenants)
model Organization {
id String @id @default(cuid())
name String
slug String @unique
subscriptionId String? @unique
plan Plan @default(FREE)
stripeCustomerId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members OrganizationMember[]
projects Project[]
subscription Subscription?
}
enum Plan {
FREE
PRO
ENTERPRISE
}
// Organization membership
model OrganizationMember {
id String @id @default(cuid())
role Role @default(MEMBER)
userId String
organizationId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([userId, organizationId])
}
enum Role {
OWNER
ADMIN
MEMBER
}
// Subscriptions
model Subscription {
id String @id @default(cuid())
organizationId String @unique
stripeSubscriptionId String @unique
stripePriceId String
stripeCurrentPeriodEnd DateTime
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
// Projects
model Project {
id String @id @default(cuid())
name String
description String?
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
tasks Task[]
@@index([organizationId])
}
// Tasks
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
projectId String
creatorId String
assigneeId String?
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
attachments Attachment[]
@@index([projectId])
@@index([assigneeId])
}
enum TaskStatus {
TODO
IN_PROGRESS
IN_REVIEW
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
// File attachments
model Attachment {
id String @id @default(cuid())
filename String
url String
size Int
taskId String
uploadedBy String
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId])
}
// NextAuth models
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}organizationId everywhere: Every tenant-specific table has organizationId for data isolation.
Cascading deletes: When an organization is deleted, all related data is automatically removed.
Indexes: Foreign keys and frequently queried fields are indexed for performance.
Enums: Type-safe status and role values prevent invalid data.
# Create .env file
DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# Run migrations
npx prisma migrate dev --name init
npx prisma generateYou now have a solid foundation with a multi-tenant database schema. Next, we'll build authentication and the organization management system.
NextAuth.js is a complete authentication solution for Next.js. It handles user registration, login, sessions, OAuth providers (Google, GitHub), and integrates seamlessly with our Prisma database.
Create app/api/auth/[...nextauth]/route.ts:
import NextAuth, { NextAuthOptions } from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import CredentialsProvider from "next-auth/providers/credentials"
import GoogleProvider from "next-auth/providers/google"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Invalid credentials")
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
})
if (!user || !user.password) {
throw new Error("Invalid credentials")
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.password
)
if (!isCorrectPassword) {
throw new Error("Invalid credentials")
}
return user
}
})
],
callbacks: {
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.sub!
// Get user's organizations
const memberships = await prisma.organizationMember.findMany({
where: { userId: token.sub! },
include: { organization: true }
})
session.user.organizations = memberships.map(m => ({
id: m.organization.id,
name: m.organization.name,
role: m.role
}))
}
return session
}
},
pages: {
signIn: '/auth/signin',
signUp: '/auth/signup',
},
session: {
strategy: "jwt"
},
secret: process.env.NEXTAUTH_SECRET,
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }Create app/auth/signup/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function SignUpPage() {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
organizationName: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Something went wrong');
}
// Redirect to sign in
router.push('/auth/signin?registered=true');
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="text-center text-3xl font-bold">Create your account</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Start your 14-day free trial
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 px-4 py-3 rounded">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Full Name
</label>
<input
id="name"
type="text"
required
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
type="email"
required
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={formData.password}
onChange={(e) => setFormData({...formData, password: e.target.value})}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="organizationName" className="block text-sm font-medium mb-2">
Organization Name
</label>
<input
id="organizationName"
type="text"
required
value={formData.organizationName}
onChange={(e) => setFormData({...formData, organizationName: e.target.value})}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded-lg font-medium disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/auth/signin" className="text-blue-500 hover:text-blue-600">
Sign in
</Link>
</p>
</form>
</div>
</div>
);
}Create app/api/auth/signup/route.ts:
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(request: Request) {
try {
const { name, email, password, organizationName } = await request.json();
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user and organization in a transaction
const result = await prisma.$transaction(async (tx) => {
// Create user
const user = await tx.user.create({
data: {
name,
email,
password: hashedPassword
}
});
// Create organization
const organization = await tx.organization.create({
data: {
name: organizationName,
slug: organizationName.toLowerCase().replace(/\s+/g, '-'),
plan: 'FREE'
}
});
// Add user as organization owner
await tx.organizationMember.create({
data: {
userId: user.id,
organizationId: organization.id,
role: 'OWNER'
}
});
return { user, organization };
});
return NextResponse.json({
message: 'Account created successfully',
userId: result.user.id
});
} catch (error) {
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
);
}
}Transaction: User and organization are created together. If one fails, both are rolled back.
Password Hashing: Never store plain passwords. bcrypt creates a secure hash.
Auto Organization: Every new user gets their own organization automatically.
Owner Role: The creator is automatically set as the organization owner.
Users can now sign up, create organizations, and authenticate. Next, we'll build the subscription system with Stripe.
Stripe handles all payment processing, subscription management, and billing. We'll create three plans (Free, Pro, Enterprise) with different feature limits and pricing. Stripe webhooks will notify us when subscriptions are created, updated, or cancelled.
First, create products in your Stripe Dashboard:
Free Plan
$0/month • 1 project • 10 tasks • 5 team members
Pro Plan
$29/month • Unlimited projects • Unlimited tasks • 50 team members
Enterprise Plan
$99/month • Everything in Pro • Priority support • Custom integrations
Create lib/stripe.ts:
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export const PLANS = {
FREE: {
name: 'Free',
price: 0,
priceId: null,
features: {
projects: 1,
tasks: 10,
members: 5,
storage: 100 // MB
}
},
PRO: {
name: 'Pro',
price: 29,
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: {
projects: -1, // unlimited
tasks: -1,
members: 50,
storage: 10000 // 10GB
}
},
ENTERPRISE: {
name: 'Enterprise',
price: 99,
priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
features: {
projects: -1,
tasks: -1,
members: -1,
storage: 100000 // 100GB
}
}
};
// Check if organization can perform action based on plan limits
export function canPerformAction(
plan: keyof typeof PLANS,
action: 'projects' | 'tasks' | 'members',
currentCount: number
): boolean {
const limit = PLANS[plan].features[action];
if (limit === -1) return true; // unlimited
return currentCount < limit;
}Create app/api/stripe/checkout/route.ts:
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { priceId, organizationId } = await request.json();
// Get organization
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
include: {
members: {
where: { userId: session.user.id }
}
}
});
if (!organization || organization.members.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 });
}
// Check if user is owner or admin
const member = organization.members[0];
if (member.role !== 'OWNER' && member.role !== 'ADMIN') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
// Create or retrieve Stripe customer
let customerId = organization.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: {
organizationId: organization.id
}
});
customerId = customer.id;
await prisma.organization.update({
where: { id: organization.id },
data: { stripeCustomerId: customerId }
});
}
// Create checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1
}
],
success_url: `${process.env.NEXTAUTH_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXTAUTH_URL}/pricing?canceled=true`,
metadata: {
organizationId: organization.id
}
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
);
}
}Create app/api/stripe/webhook/route.ts to handle subscription events:
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const session = event.data.object as Stripe.Checkout.Session;
// Handle different event types
switch (event.type) {
case 'checkout.session.completed':
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await prisma.subscription.create({
data: {
organizationId: session.metadata!.organizationId,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
status: subscription.status
}
});
// Update organization plan
const priceId = subscription.items.data[0].price.id;
let plan = 'FREE';
if (priceId === process.env.STRIPE_PRO_PRICE_ID) plan = 'PRO';
if (priceId === process.env.STRIPE_ENTERPRISE_PRICE_ID) plan = 'ENTERPRISE';
await prisma.organization.update({
where: { id: session.metadata!.organizationId },
data: { plan }
});
break;
case 'invoice.payment_succeeded':
// Update subscription period
const invoiceSubscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await prisma.subscription.update({
where: { stripeSubscriptionId: invoiceSubscription.id },
data: {
stripePriceId: invoiceSubscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(invoiceSubscription.current_period_end * 1000),
status: invoiceSubscription.status
}
});
break;
case 'customer.subscription.deleted':
// Downgrade to free plan
await prisma.subscription.delete({
where: { stripeSubscriptionId: session.id }
});
const org = await prisma.organization.findFirst({
where: { stripeCustomerId: session.customer as string }
});
if (org) {
await prisma.organization.update({
where: { id: org.id },
data: { plan: 'FREE' }
});
}
break;
}
return NextResponse.json({ received: true });
}checkout.session.completed: User completed payment. Create subscription record and upgrade plan.
invoice.payment_succeeded: Monthly payment succeeded. Update subscription period.
customer.subscription.deleted: User cancelled. Downgrade to free plan.
Your SaaS now has a complete subscription system with Stripe. Users can upgrade, downgrade, and manage billing.
tRPC provides end-to-end type safety between your backend and frontend. No need to manually type API responses - TypeScript knows exactly what data your API returns. It's like having your backend and frontend speak the same language automatically.
Create server/trpc.ts:
import { initTRPC, TRPCError } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/prisma';
// Create context for each request
export async function createContext() {
const session = await getServerSession(authOptions);
return { session, prisma };
}
const t = initTRPC.context<typeof createContext>().create();
// Middleware to check authentication
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session,
prisma: ctx.prisma
}
});
});
// Middleware to check organization access
const hasOrgAccess = t.middleware(async ({ ctx, input, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const { organizationId } = input as { organizationId: string };
const member = await ctx.prisma.organizationMember.findUnique({
where: {
userId_organizationId: {
userId: ctx.session.user.id,
organizationId
}
},
include: { organization: true }
});
if (!member) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({
ctx: {
...ctx,
organization: member.organization,
userRole: member.role
}
});
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
export const orgProcedure = t.procedure.use(isAuthed).use(hasOrgAccess);Create server/routers/project.ts:
import { z } from 'zod';
import { router, orgProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
import { canPerformAction, PLANS } from '@/lib/stripe';
export const projectRouter = router({
// Get all projects for an organization
list: orgProcedure
.input(z.object({
organizationId: z.string()
}))
.query(async ({ ctx, input }) => {
return ctx.prisma.project.findMany({
where: { organizationId: input.organizationId },
include: {
_count: {
select: { tasks: true }
}
},
orderBy: { createdAt: 'desc' }
});
}),
// Create a new project
create: orgProcedure
.input(z.object({
organizationId: z.string(),
name: z.string().min(1).max(100),
description: z.string().optional()
}))
.mutation(async ({ ctx, input }) => {
// Check plan limits
const projectCount = await ctx.prisma.project.count({
where: { organizationId: input.organizationId }
});
if (!canPerformAction(ctx.organization.plan, 'projects', projectCount)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `You've reached the project limit for your ${ctx.organization.plan} plan`
});
}
return ctx.prisma.project.create({
data: {
name: input.name,
description: input.description,
organizationId: input.organizationId
}
});
}),
// Update project
update: orgProcedure
.input(z.object({
organizationId: z.string(),
projectId: z.string(),
name: z.string().min(1).max(100).optional(),
description: z.string().optional()
}))
.mutation(async ({ ctx, input }) => {
// Verify project belongs to organization
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
organizationId: input.organizationId
}
});
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return ctx.prisma.project.update({
where: { id: input.projectId },
data: {
name: input.name,
description: input.description
}
});
}),
// Delete project
delete: orgProcedure
.input(z.object({
organizationId: z.string(),
projectId: z.string()
}))
.mutation(async ({ ctx, input }) => {
// Only owners and admins can delete
if (ctx.userRole !== 'OWNER' && ctx.userRole !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return ctx.prisma.project.delete({
where: {
id: input.projectId,
organizationId: input.organizationId
}
});
})
});Create server/routers/task.ts:
import { z } from 'zod';
import { router, orgProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
import { canPerformAction } from '@/lib/stripe';
export const taskRouter = router({
// Get tasks for a project
list: orgProcedure
.input(z.object({
organizationId: z.string(),
projectId: z.string(),
status: z.enum(['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']).optional()
}))
.query(async ({ ctx, input }) => {
return ctx.prisma.task.findMany({
where: {
projectId: input.projectId,
project: { organizationId: input.organizationId },
...(input.status && { status: input.status })
},
include: {
creator: { select: { id: true, name: true, image: true } },
assignee: { select: { id: true, name: true, image: true } },
_count: { select: { attachments: true } }
},
orderBy: { createdAt: 'desc' }
});
}),
// Create task
create: orgProcedure
.input(z.object({
organizationId: z.string(),
projectId: z.string(),
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']),
assigneeId: z.string().optional(),
dueDate: z.date().optional()
}))
.mutation(async ({ ctx, input }) => {
// Check plan limits
const taskCount = await ctx.prisma.task.count({
where: {
project: { organizationId: input.organizationId }
}
});
if (!canPerformAction(ctx.organization.plan, 'tasks', taskCount)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `You've reached the task limit for your ${ctx.organization.plan} plan`
});
}
// Verify project exists and belongs to organization
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
organizationId: input.organizationId
}
});
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return ctx.prisma.task.create({
data: {
title: input.title,
description: input.description,
priority: input.priority,
projectId: input.projectId,
creatorId: ctx.session.user.id,
assigneeId: input.assigneeId,
dueDate: input.dueDate
},
include: {
creator: { select: { id: true, name: true, image: true } },
assignee: { select: { id: true, name: true, image: true } }
}
});
}),
// Update task
update: orgProcedure
.input(z.object({
organizationId: z.string(),
taskId: z.string(),
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
status: z.enum(['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']).optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).optional(),
assigneeId: z.string().optional(),
dueDate: z.date().optional()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.task.update({
where: { id: input.taskId },
data: {
title: input.title,
description: input.description,
status: input.status,
priority: input.priority,
assigneeId: input.assigneeId,
dueDate: input.dueDate
}
});
}),
// Delete task
delete: orgProcedure
.input(z.object({
organizationId: z.string(),
taskId: z.string()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.task.delete({
where: { id: input.taskId }
});
})
});Plan Limits: Automatically enforces project and task limits based on subscription plan.
Organization Isolation: All queries are automatically scoped to the user's organization.
Type Safety: Full TypeScript types from backend to frontend with zero manual typing.
Validation: Zod schemas validate all inputs before they reach your database.
Your SaaS now has fully functional project and task management with plan-based limits and complete type safety.
WebSockets enable real-time, bidirectional communication between the server and clients. When one user updates a task, all other team members see the change instantly without refreshing. This is essential for collaborative applications.
Create server/socket.ts:
import { Server as HTTPServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { getToken } from 'next-auth/jwt';
export function initializeSocket(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.NEXTAUTH_URL,
credentials: true
}
});
// Authentication middleware
io.use(async (socket, next) => {
try {
const token = await getToken({
req: socket.request as any,
secret: process.env.NEXTAUTH_SECRET
});
if (!token) {
return next(new Error('Unauthorized'));
}
socket.data.userId = token.sub;
next();
} catch (error) {
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
console.log('User connected:', socket.data.userId);
// Join organization room
socket.on('join-organization', (organizationId: string) => {
socket.join(`org:${organizationId}`);
console.log(`User ${socket.data.userId} joined org ${organizationId}`);
});
// Join project room
socket.on('join-project', (projectId: string) => {
socket.join(`project:${projectId}`);
});
// Leave rooms
socket.on('leave-organization', (organizationId: string) => {
socket.leave(`org:${organizationId}`);
});
socket.on('leave-project', (projectId: string) => {
socket.leave(`project:${projectId}`);
});
// Typing indicators
socket.on('typing-start', ({ projectId, userName }) => {
socket.to(`project:${projectId}`).emit('user-typing', {
userId: socket.data.userId,
userName
});
});
socket.on('typing-stop', ({ projectId }) => {
socket.to(`project:${projectId}`).emit('user-stopped-typing', {
userId: socket.data.userId
});
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.data.userId);
});
});
return io;
}
// Emit events to rooms
export function emitToOrganization(
io: SocketIOServer,
organizationId: string,
event: string,
data: any
) {
io.to(`org:${organizationId}`).emit(event, data);
}
export function emitToProject(
io: SocketIOServer,
projectId: string,
event: string,
data: any
) {
io.to(`project:${projectId}`).emit(event, data);
}Update task router to emit real-time events:
// In server/routers/task.ts - add to create mutation
.mutation(async ({ ctx, input }) => {
// ... existing code ...
const task = await ctx.prisma.task.create({
data: { /* ... */ },
include: {
creator: { select: { id: true, name: true, image: true } },
assignee: { select: { id: true, name: true, image: true } }
}
});
// Emit real-time event
if (ctx.io) {
emitToProject(ctx.io, input.projectId, 'task-created', task);
}
return task;
});
// Similar for update mutation
.mutation(async ({ ctx, input }) => {
const task = await ctx.prisma.task.update({
where: { id: input.taskId },
data: { /* ... */ }
});
// Emit update event
if (ctx.io) {
const project = await ctx.prisma.task.findUnique({
where: { id: input.taskId },
select: { projectId: true }
});
if (project) {
emitToProject(ctx.io, project.projectId, 'task-updated', task);
}
}
return task;
});Create hooks/useSocket.ts:
'use client';
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useSession } from 'next-auth/react';
export function useSocket() {
const { data: session } = useSession();
const [socket, setSocket] = useState<Socket | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!session) return;
const socketInstance = io(process.env.NEXT_PUBLIC_SOCKET_URL || '', {
withCredentials: true
});
socketInstance.on('connect', () => {
console.log('Socket connected');
setConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('Socket disconnected');
setConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, [session]);
return { socket, connected };
}
// Hook for project-specific real-time updates
export function useProjectSocket(projectId: string | null) {
const { socket, connected } = useSocket();
useEffect(() => {
if (!socket || !projectId || !connected) return;
socket.emit('join-project', projectId);
return () => {
socket.emit('leave-project', projectId);
};
}, [socket, projectId, connected]);
return { socket, connected };
}
// Hook for listening to task updates
export function useTaskUpdates(
projectId: string | null,
onTaskCreated?: (task: any) => void,
onTaskUpdated?: (task: any) => void,
onTaskDeleted?: (taskId: string) => void
) {
const { socket } = useProjectSocket(projectId);
useEffect(() => {
if (!socket) return;
if (onTaskCreated) {
socket.on('task-created', onTaskCreated);
}
if (onTaskUpdated) {
socket.on('task-updated', onTaskUpdated);
}
if (onTaskDeleted) {
socket.on('task-deleted', onTaskDeleted);
}
return () => {
socket.off('task-created');
socket.off('task-updated');
socket.off('task-deleted');
};
}, [socket, onTaskCreated, onTaskUpdated, onTaskDeleted]);
}'use client';
import { useState } from 'react';
import { useTaskUpdates } from '@/hooks/useSocket';
import { trpc } from '@/lib/trpc';
export default function TaskBoard({ projectId }: { projectId: string }) {
const [tasks, setTasks] = useState<Task[]>([]);
// Fetch initial tasks
const { data } = trpc.task.list.useQuery({
organizationId: currentOrgId,
projectId
});
// Listen for real-time updates
useTaskUpdates(
projectId,
// On task created
(newTask) => {
setTasks(prev => [newTask, ...prev]);
// Show notification
toast.success(`New task created: ${newTask.title}`);
},
// On task updated
(updatedTask) => {
setTasks(prev => prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
));
},
// On task deleted
(taskId) => {
setTasks(prev => prev.filter(task => task.id !== taskId));
}
);
return (
<div className="space-y-4">
{tasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
);
}Instant Updates: All team members see changes immediately without refreshing.
Typing Indicators: See when teammates are typing in real-time.
Presence: Know who's currently viewing the project.
Notifications: Get instant alerts for important updates.
Your SaaS now has real-time collaboration features. Team members can work together seamlessly with instant updates.
Allow organization owners to invite team members via email:
// server/routers/team.ts
import { z } from 'zod';
import { router, orgProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
import { sendEmail } from '@/lib/email';
export const teamRouter = router({
// Invite team member
invite: orgProcedure
.input(z.object({
organizationId: z.string(),
email: z.string().email(),
role: z.enum(['ADMIN', 'MEMBER'])
}))
.mutation(async ({ ctx, input }) => {
// Only owners and admins can invite
if (ctx.userRole !== 'OWNER' && ctx.userRole !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// Check member limit
const memberCount = await ctx.prisma.organizationMember.count({
where: { organizationId: input.organizationId }
});
if (!canPerformAction(ctx.organization.plan, 'members', memberCount)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `You've reached the member limit for your ${ctx.organization.plan} plan`
});
}
// Create invitation token
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await ctx.prisma.invitation.create({
data: {
email: input.email,
organizationId: input.organizationId,
role: input.role,
token,
expiresAt,
invitedBy: ctx.session.user.id
}
});
// Send invitation email
await sendEmail({
to: input.email,
subject: `You've been invited to join ${ctx.organization.name}`,
html: `
<h1>Join ${ctx.organization.name} on TaskFlow Pro</h1>
<p>You've been invited to collaborate on projects.</p>
<a href="${process.env.NEXTAUTH_URL}/invite/${token}">
Accept Invitation
</a>
<p>This invitation expires in 7 days.</p>
`
});
return { success: true };
}),
// Accept invitation
acceptInvite: protectedProcedure
.input(z.object({
token: z.string()
}))
.mutation(async ({ ctx, input }) => {
const invitation = await ctx.prisma.invitation.findUnique({
where: { token: input.token },
include: { organization: true }
});
if (!invitation || invitation.expiresAt < new Date()) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired invitation' });
}
// Create organization member
await ctx.prisma.organizationMember.create({
data: {
userId: ctx.session.user.id,
organizationId: invitation.organizationId,
role: invitation.role
}
});
// Delete invitation
await ctx.prisma.invitation.delete({
where: { id: invitation.id }
});
return { organization: invitation.organization };
}),
// List team members
list: orgProcedure
.input(z.object({
organizationId: z.string()
}))
.query(async ({ ctx, input }) => {
return ctx.prisma.organizationMember.findMany({
where: { organizationId: input.organizationId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
},
orderBy: { createdAt: 'asc' }
});
}),
// Remove team member
remove: orgProcedure
.input(z.object({
organizationId: z.string(),
memberId: z.string()
}))
.mutation(async ({ ctx, input }) => {
// Only owners can remove members
if (ctx.userRole !== 'OWNER') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// Can't remove yourself
const member = await ctx.prisma.organizationMember.findUnique({
where: { id: input.memberId }
});
if (member?.userId === ctx.session.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: "You can't remove yourself"
});
}
return ctx.prisma.organizationMember.delete({
where: { id: input.memberId }
});
})
});Enable users to attach files to tasks:
// lib/s3.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
}
});
export async function getUploadUrl(
filename: string,
contentType: string,
organizationId: string
): Promise<{ uploadUrl: string; fileUrl: string }> {
const key = `${organizationId}/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType
});
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
const fileUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
return { uploadUrl, fileUrl };
}
// server/routers/attachment.ts
export const attachmentRouter = router({
// Get upload URL
getUploadUrl: orgProcedure
.input(z.object({
organizationId: z.string(),
filename: z.string(),
contentType: z.string(),
size: z.number()
}))
.mutation(async ({ ctx, input }) => {
// Check storage limit
const usedStorage = await ctx.prisma.attachment.aggregate({
where: {
task: {
project: { organizationId: input.organizationId }
}
},
_sum: { size: true }
});
const storageLimit = PLANS[ctx.organization.plan].features.storage * 1024 * 1024; // Convert MB to bytes
const currentUsage = usedStorage._sum.size || 0;
if (currentUsage + input.size > storageLimit) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Storage limit exceeded for your plan'
});
}
return getUploadUrl(input.filename, input.contentType, input.organizationId);
}),
// Create attachment record after upload
create: orgProcedure
.input(z.object({
organizationId: z.string(),
taskId: z.string(),
filename: z.string(),
url: z.string(),
size: z.number()
}))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.attachment.create({
data: {
filename: input.filename,
url: input.url,
size: input.size,
taskId: input.taskId,
uploadedBy: ctx.session.user.id
}
});
})
});'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
export function FileUpload({ taskId, organizationId }: Props) {
const [uploading, setUploading] = useState(false);
const getUploadUrl = trpc.attachment.getUploadUrl.useMutation();
const createAttachment = trpc.attachment.create.useMutation();
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Get signed upload URL
const { uploadUrl, fileUrl } = await getUploadUrl.mutateAsync({
organizationId,
filename: file.name,
contentType: file.type,
size: file.size
});
// Upload file to S3
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
// Create attachment record
await createAttachment.mutateAsync({
organizationId,
taskId,
filename: file.name,
url: fileUrl,
size: file.size
});
toast.success('File uploaded successfully');
} catch (error) {
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="cursor-pointer px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
{uploading ? 'Uploading...' : 'Attach File'}
</label>
</div>
);
}Your SaaS now supports team collaboration with invitations and file attachments with storage limits per plan.
Deploying a SaaS application requires more than just pushing code. You need to set up databases, configure environment variables, set up monitoring, and ensure everything is secure and scalable.
# Option 1: Supabase (Recommended for beginners)
1. Go to supabase.com and create a new project
2. Copy the connection string from Settings > Database
3. Add to your .env:
DATABASE_URL="postgresql://postgres:[password]@[host]:5432/postgres"
# Option 2: Neon (Serverless PostgreSQL)
1. Go to neon.tech and create a project
2. Copy the connection string
3. Add to your .env
# Run migrations on production database
npx prisma migrate deploy
npx prisma generate# Install Vercel CLI
npm i -g vercel
# Login to Vercel
vercel login
# Deploy
vercel
# Set environment variables in Vercel dashboard:
DATABASE_URL=your_production_database_url
NEXTAUTH_SECRET=your_production_secret
NEXTAUTH_URL=https://your-domain.com
STRIPE_SECRET_KEY=your_stripe_secret
STRIPE_WEBHOOK_SECRET=your_webhook_secret
AWS_ACCESS_KEY_ID=your_aws_key
AWS_SECRET_ACCESS_KEY=your_aws_secret
AWS_S3_BUCKET=your_bucket_name
AWS_REGION=us-east-1
# Deploy to production
vercel --prodSet up webhooks in Stripe Dashboard to receive subscription events:
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click "Add endpoint"
3. Enter: https://your-domain.com/api/stripe/webhook
4. Select events:
5. Copy the webhook signing secret to your environment variables
# Install Sentry for error tracking
npm install @sentry/nextjs
# Initialize Sentry
npx @sentry/wizard@latest -i nextjs
# Add to your code for error tracking
import * as Sentry from "@sentry/nextjs";
try {
// Your code
} catch (error) {
Sentry.captureException(error);
throw error;
}
# Set up Vercel Analytics (built-in)
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}// Enable Redis caching for frequently accessed data
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!
});
// Cache organization data
export async function getOrganization(id: string) {
const cacheKey = `org:${id}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) return cached;
// Fetch from database
const org = await prisma.organization.findUnique({
where: { id }
});
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(org));
return org;
}
// Implement rate limiting
import { Ratelimit } from '@upstash/ratelimit';
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
});
export async function checkRateLimit(userId: string) {
const { success } = await ratelimit.limit(userId);
if (!success) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
}
}# Test critical flows:
1. User registration and login
2. Organization creation
3. Subscription upgrade (use Stripe test mode)
4. Project and task creation
5. Team invitations
6. File uploads
7. Real-time updates (open in multiple browsers)
8. Webhook delivery (check Stripe dashboard)
# Monitor for errors:
- Check Sentry dashboard for any exceptions
- Review Vercel logs for API errors
- Monitor database performance in Supabase/Neon
- Check Stripe webhook delivery statusYour SaaS application is now live in production! You've built a complete, scalable, multi-tenant application with subscriptions, real-time features, and team collaboration.
Frontend
Next.js 15, React, TypeScript, Tailwind CSS
Backend
tRPC, Prisma, PostgreSQL, Redis
Infrastructure
Vercel, AWS S3, Stripe, WebSockets
You've completed the Full-Stack Mastery program and built a production-ready SaaS application. You now have the skills to build and deploy complex, scalable web applications from scratch.