Skip to main content
All posts

Building SaaS Applications with React and TypeScript: A Complete Guide

16 June 2026

Building SaaS Applications with React and TypeScript: A Complete Guide

I’ve built CitizenApp with 9 AI features serving multiple tenants. I’ve also watched countless projects fail because their frontend architecture couldn’t scale when the backend did. Here’s what I learned shipping production SaaS.

Why React + TypeScript Isn’t Optional for SaaS

I prefer React because it’s the only frontend framework where enterprise patterns feel natural rather than bolted-on. TypeScript isn’t a luxury—it’s a moat. When you’re building multi-tenant systems with role-based access control, type safety catches tenant isolation bugs before they become security incidents.

The combination lets you:

I’ve seen projects burn real money because someone deployed a feature with wrong tenant scoping. TypeScript would’ve caught it at compile time.

The SaaS Architecture Pattern That Works

Here’s the actual architecture I use:

// src/types/tenant.ts
export interface Tenant {
  id: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
  features: string[];
}

export interface AuthContext {
  userId: string;
  tenantId: string;
  role: 'admin' | 'member' | 'viewer';
  permissions: Set<string>;
}

// src/hooks/useAuth.ts
import { useContext, useCallback } from 'react';
import { AuthContextValue } from '@/context/AuthContext';

export function useAuth() {
  const context = useContext(AuthContextValue);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  
  return {
    ...context,
    can: useCallback((action: string) => {
      return context.permissions.has(action);
    }, [context.permissions]),
  };
}

// src/components/FeatureGate.tsx
interface FeatureGateProps {
  feature: string;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
  const auth = useAuth();
  
  if (!auth.tenant.features.includes(feature)) {
    return fallback || null;
  }
  
  return <>{children}</>;
}

This pattern matters because:

  1. Type-safe permissions - You can’t accidentally check a permission that doesn’t exist
  2. Feature flags baked in - Your frontend knows what the tenant can access
  3. Composable access control - Stack permissions without spaghetti conditionals

API Layer: Where Frontend Meets Backend

I prefer generating API clients with OpenAPI/Zod rather than hand-writing them. Here’s why: when your FastAPI backend changes an endpoint, you want type errors on the frontend, not runtime bugs.

// src/api/client.ts
import { z } from 'zod';
import { apiRequest } from './request';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  tenantId: z.string(),
  role: z.enum(['admin', 'member', 'viewer']),
});

export type User = z.infer<typeof UserSchema>;

export async function getUser(userId: string): Promise<User> {
  const response = await apiRequest(`/api/users/${userId}`);
  return UserSchema.parse(response);
}

// This runtime validation catches malformed API responses
// More than once, a backend update returned an unexpected field type

The Python side should mirror this contract:

# backend/app/schemas.py
from pydantic import BaseModel, EmailStr
from enum import Enum

class UserRole(str, Enum):
    admin = "admin"
    member = "member"
    viewer = "viewer"

class UserResponse(BaseModel):
    id: str
    email: EmailStr
    tenant_id: str
    role: UserRole

# backend/app/routes/users.py
from fastapi import APIRouter, Depends
from app.auth import verify_tenant_access
from app.schemas import UserResponse

router = APIRouter()

@router.get("/api/users/{user_id}", response_model=UserResponse)
async def get_user(
    user_id: str,
    current_tenant: str = Depends(verify_tenant_access),
):
    # Query user, ensure they belong to current_tenant
    user = db.query(User).filter(
        User.id == user_id,
        User.tenant_id == current_tenant,
    ).first()
    
    if not user:
        raise NotFound()
    
    return user

This pattern enforces tenant isolation at every layer. The backend verifies tenant access on every endpoint. No exceptions. I’ve burned time debugging “why does user A see user B’s data?” Only to find one endpoint forgot the tenant filter.

State Management: Keep It Boring

I prefer TanStack Query (React Query) for server state and Zustand for client state. Here’s the real reason: I’ve seen projects collapse under Redux boilerplate that could’ve been Query + Zustand in 20% of the code.

// src/hooks/useTenantUsers.ts
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '@/api/client';

export function useTenantUsers() {
  const { tenantId } = useAuth();
  
  return useQuery({
    queryKey: ['users', tenantId],
    queryFn: () => getUsers(tenantId),
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}

// src/store/uiStore.ts
import { create } from 'zustand';

interface UIStore {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

export const useUIStore = create<UIStore>((set) => ({
  sidebarOpen: true,
  toggleSidebar: () => set((state) => ({
    sidebarOpen: !state.sidebarOpen,
  })),
}));

Query handles caching, refetching, background updates. Zustand handles UI state. Done. No 200-line reducer files for “toggle sidebar.”

Deployment: TypeScript All the Way

When you deploy to Vercel or Cloudflare Pages, TypeScript compilation catches errors before runtime. I’ve seen untyped projects ship undefined is not a function on prod. Not fun.

// astro.config.mjs
export default defineConfig({
  integrations: [react()],
  // Enforce TypeScript strictness
  vite: {
    ssr: {
      external: ['shared-types'], // Share types with backend
    },
  },
});

Gotcha: Type-Safety Theatre Doesn’t Prevent Logic Errors

Here’s what burned me: I had type-safe tenant checks everywhere. But I forgot that a user could have different roles across different tenants. The same userId on tenant A is admin, on tenant B is viewer.

// This looks safe but is actually wrong:
const isAdmin = user.role === 'admin'; // Which tenant?

// Correct version:
const isAdmin = user.role === 'admin' && user.tenantId === currentTenant.id;

TypeScript stopped me from typos. It didn’t stop me from logic bugs. Code review and tests caught this. TypeScript is a floor, not a ceiling.

What I Missed

I built CitizenApp’s first version without a shared types package. The frontend types and backend schemas drifted. It took a full day to resync them. Now I use:

npm create @openapi-generator/cli -- --generate-client

This generates TypeScript types directly from OpenAPI specs. One source of truth. Zero drift.

The Real Pattern

SaaS architecture isn’t about picking the “best” tool. It’s about building guardrails so mistakes are expensive at compile-time, not production-time. React + TypeScript + TanStack Query + Zod creates those guardrails cheaply.

Tenant isolation. Type safety. Boring state management. Deploy with confidence. That’s the formula.

You might also like

Comments

All comments are moderated before appearing.

Loading comments…

Leave a comment

0/2000

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch