Back to Full-Stack Mastery

Module 7: Complete Project - SaaS Application

Build a production-ready multi-tenant SaaS platform from scratch with subscriptions, team collaboration, and real-time features

🎯 Project: TaskFlow Pro

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.

Core Features:

User authentication & authorization
Multi-tenant architecture
Subscription plans (Free, Pro, Enterprise)
Stripe payment integration
Team collaboration & invitations
Real-time updates with WebSockets
Admin dashboard & analytics
Email notifications
File uploads to S3
API rate limiting per plan
Comprehensive testing
Production deployment

Tech Stack:

Frontend

Next.js 15, TypeScript, Tailwind

Backend

Next.js API Routes, tRPC

Database

PostgreSQL, Prisma, Redis

Services

Stripe, AWS S3, SendGrid

🏗️ Understanding SaaS Architecture

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 Explained

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.

🔑 Key Concepts:

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.

Database Schema Design

Our database schema is designed around organizations. Every major entity (projects, tasks, files) belongs to an organization, ensuring complete data isolation between tenants.

Phase 1: Project Foundation & Database Setup

📖 Setting Up the Foundation

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.

Step 1: Initialize the Project

# 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 prisma

Step 2: Database Schema with Prisma

Create 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])
}

💡 Schema Design Principles:

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.

Step 3: Initialize Database

# 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 generate

✅ Phase 1 Complete!

You now have a solid foundation with a multi-tenant database schema. Next, we'll build authentication and the organization management system.

Phase 2: Authentication & Authorization

📖 Understanding NextAuth.js

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.

Step 1: Configure NextAuth

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 }

Step 2: Create Sign Up Page

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

Step 3: Sign Up API Route

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

💡 What's Happening Here:

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.

✅ Phase 2 Complete!

Users can now sign up, create organizations, and authenticate. Next, we'll build the subscription system with Stripe.

Phase 3: Subscription & Billing with Stripe

📖 Understanding Stripe Integration

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.

Step 1: Set Up Stripe Products

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

Step 2: Stripe Configuration

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

Step 3: Checkout API Route

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

Step 4: Stripe Webhooks

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

💡 Webhook Events Explained:

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.

✅ Phase 3 Complete!

Your SaaS now has a complete subscription system with Stripe. Users can upgrade, downgrade, and manage billing.

Phase 4: Core Features - Projects & Tasks

📖 Building with tRPC

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.

Step 1: tRPC Setup

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

Step 2: Project Router

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

Step 3: Task Router

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

💡 Key Features Implemented:

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.

✅ Phase 4 Complete!

Your SaaS now has fully functional project and task management with plan-based limits and complete type safety.

Phase 5: Real-Time Updates with WebSockets

📖 Understanding WebSockets

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.

Step 1: WebSocket Server Setup

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

Step 2: Integrate WebSocket with tRPC

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

Step 3: Client-Side WebSocket Hook

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

Step 4: Using Real-Time Updates in Components

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

💡 Real-Time Features:

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.

✅ Phase 5 Complete!

Your SaaS now has real-time collaboration features. Team members can work together seamlessly with instant updates.

Phase 6: Team Collaboration & File Uploads

Team Invitations

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

File Uploads to AWS S3

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

Client-Side File Upload Component

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

✅ Phase 6 Complete!

Your SaaS now supports team collaboration with invitations and file attachments with storage limits per plan.

Phase 7: Production Deployment

📖 Preparing for Production

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.

Step 1: Database Setup (Supabase/Neon)

# 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

Step 2: Deploy to Vercel

# 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 --prod

Step 3: Configure Stripe Webhooks

Set 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:

  • • checkout.session.completed
  • • invoice.payment_succeeded
  • • customer.subscription.deleted
  • • customer.subscription.updated

5. Copy the webhook signing secret to your environment variables

Step 4: Set Up Monitoring

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

Step 5: Performance Optimization

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

Step 6: Security Checklist

All environment variables are set in Vercel (never commit secrets)
Database has SSL enabled and connection pooling configured
CORS is properly configured for your domain only
Rate limiting is enabled on all API routes
All user inputs are validated with Zod schemas
SQL injection protection (Prisma handles this automatically)
XSS protection (React escapes by default, but verify)
HTTPS is enforced (Vercel does this automatically)

Step 7: Post-Deployment Testing

# 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 status

🎉 Phase 7 Complete!

Your SaaS application is now live in production! You've built a complete, scalable, multi-tenant application with subscriptions, real-time features, and team collaboration.

🏆 What You've Built

Technical Skills Mastered:

  • ✓ Multi-tenant SaaS architecture
  • ✓ Authentication & authorization
  • ✓ Subscription billing with Stripe
  • ✓ Real-time features with WebSockets
  • ✓ File uploads to S3
  • ✓ Team collaboration features
  • ✓ Database design & optimization
  • ✓ API development with tRPC
  • ✓ Production deployment
  • ✓ Monitoring & error tracking

Business Features Implemented:

  • ✓ User registration & onboarding
  • ✓ Organization management
  • ✓ Tiered pricing (Free, Pro, Enterprise)
  • ✓ Plan-based feature limits
  • ✓ Team invitations & roles
  • ✓ Project & task management
  • ✓ File attachments
  • ✓ Real-time collaboration
  • ✓ Email notifications
  • ✓ Analytics & monitoring

Architecture Highlights:

Frontend

Next.js 15, React, TypeScript, Tailwind CSS

Backend

tRPC, Prisma, PostgreSQL, Redis

Infrastructure

Vercel, AWS S3, Stripe, WebSockets

Next Steps to Grow Your SaaS:

Add more integrations (Slack, GitHub, etc.)
Implement advanced analytics dashboard
Add mobile apps (React Native)
Build public API for third-party developers
Implement AI features (task suggestions, etc.)
Add comprehensive testing (E2E, unit tests)

Congratulations! 🎉

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.