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:
- Share types between frontend and backend (critical for API contracts)
- Refactor with confidence when architecture changes
- Onboard engineers without a 3-week ramp period
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:
- Type-safe permissions - You can’t accidentally check a permission that doesn’t exist
- Feature flags baked in - Your frontend knows what the tenant can access
- 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.
Comments
All comments are moderated before appearing.
Leave a comment