Web Architecture Patterns for Full-Stack TypeScript Applications
I've shipped CitizenApp from zero to production SaaS handling thousands of concurrent users. Along the way, I've made every architectural mistake worth making—and some worth repeating to be sure. Here's what actually works when you're building full-stack TypeScript apps that need to scale.
The Monorepo-First Decision
I strongly prefer monorepo architecture for TypeScript full-stacks. Specifically, a pnpm workspace with separate frontend and backend packages.
Why? Because your React frontend and FastAPI backend are not separate products—they're one product with two deployment targets. A monorepo forces you to think about this correctly from day one.
{
"workspaces": [
"apps/web",
"apps/api",
"packages/types",
"packages/hooks"
]
}
The killer feature: a shared packages/types directory. Your TypeScript types for API responses live in one place. When you change a response shape on the backend, TypeScript catches it immediately in your React components.
// packages/types/index.ts
export interface Tenant {
id: string;
name: string;
tier: "free" | "pro" | "enterprise";
createdAt: Date;
}
export interface AIFeatureUsage {
featureId: string;
tokensUsed: number;
requestsToday: number;
}
Your frontend imports directly:
// apps/web/src/hooks/useTenant.ts
import type { Tenant, AIFeatureUsage } from "@my-org/types";
export function useTenant() {
const [tenant, setTenant] = useState<Tenant | null>(null);
// ...
}
I skipped this on an early project and spent weeks debugging frontend/backend contract mismatches. Never again.
API Boundary Clarity: The 80/20 Rule
Your API boundary is where architectural decisions compound fastest. I organize FastAPI endpoints around business domains, not CRUD tables.
# apps/api/routers/ai_features.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/ai-features", tags=["AI Features"])
@router.post("/generate-summary")
async def generate_summary(
request: SummarizeRequest,
tenant_id: str = Depends(get_current_tenant),
db: Session = Depends(get_db),
claude_client = Depends(get_anthropic_client),
):
# Check usage limits (business logic, not just database)
usage = await check_tenant_usage(tenant_id, db)
if usage.tokens_used >= usage.monthly_limit:
raise HTTPException(status_code=429, detail="Monthly token limit exceeded")
# Call Claude
response = await claude_client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{"role": "user", "content": request.text}]
)
# Log for billing
await log_feature_usage(tenant_id, "summarize", response.usage.output_tokens, db)
return {"summary": response.content[0].text}
Why domain-driven endpoints? Because "generate summary" is a feature, not a CRUD operation. It bundles business logic (usage checks), external API calls (Claude), and audit logging into one coherent unit. This scales cleaner than trying to layer business logic across fifty generic /users/{id}/posts/{postId} endpoints.
State Management: Server State Over Client State
This is where React 19 changes the game. Use React Server Components + Server Actions for most state. Keep client state minimal.
I used to maintain complex Zustand + TanStack Query setups. Over-engineered. Now:
// apps/web/src/components/TenantDashboard.tsx
import { getTenantMetrics } from "@/server-actions/metrics";
export default async function TenantDashboard({ tenantId }) {
const metrics = await getTenantMetrics(tenantId);
return (
<div className="grid grid-cols-3 gap-4">
<MetricCard label="Requests Today" value={metrics.requestsToday} />
<MetricCard label="Tokens Used" value={metrics.tokensUsed} />
<AIFeatureUsageChart data={metrics.usageByFeature} />
</div>
);
}
Server Actions handle mutations:
// apps/web/src/server-actions/ai-features.ts
"use server"
import { verifyTenantAccess, logFeatureUsage } from "@/lib/auth";
import { anthropic } from "@/lib/clients";
import { revalidatePath } from "next/cache";
export async function generateSummary(tenantId: string, text: string) {
await verifyTenantAccess(tenantId);
const response = await anthropic.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages: [{ role: "user", content: text }]
});
await logFeatureUsage(tenantId, "summarize", response.usage.output_tokens);
revalidatePath(`/dashboard/${tenantId}`);
return response.content[0].text;
}
Why? Because your database is the source of truth, not your React component state. Render from the database, mutate through verified server functions, revalidate. This eliminates entire classes of bugs.
Multi-Tenancy: Isolation at the Database Layer
I've seen multi-tenancy bolted on as an afterthought. Architect it in from the start.
CREATE TABLE tenants (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
stripe_customer_id VARCHAR(255),
tier VARCHAR(50) DEFAULT 'free'
);
CREATE TABLE ai_feature_usage (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
feature_name VARCHAR(100),
tokens_used INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT no_cross_tenant_queries
CHECK (tenant_id IS NOT NULL)
);
CREATE INDEX idx_usage_tenant_created ON ai_feature_usage(tenant_id, created_at);
Every query must filter by tenant_id. SQLAlchemy makes this friction-free with event listeners:
from sqlalchemy import event
from sqlalchemy.orm import Session
@event.listens_for(Session, "before_flush")
def auto_tenant_filter(session, *args):
"""Automatically include tenant_id in all queries"""
pass # Implementation omitted, but framework handles it
# Usage
usage = db.query(AIFeatureUsage).filter(
AIFeatureUsage.tenant_id == current_tenant_id
).all()
# Query *always* scoped to tenant
Gotcha: JWT Token Expiration in Multi-Page Apps
I burned an hour debugging this: If you use JWT for auth, implement silent refresh before token expiry.
React 19 Server Components don't automatically refresh tokens between page navigations. If a user clicks through your SaaS dashboard and their JWT expires mid-session, you'll get auth errors that confuse everyone.
// apps/web/src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("authToken")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Decode JWT (install jsonwebtoken)
const decoded = jwtDecode(token);
const expiresIn = decoded.exp * 1000 - Date.now();
// Refresh if expires in < 5 minutes
if (expiresIn < 5 * 60 * 1000) {
return NextResponse.redirect(new URL("/api/auth/refresh", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
The Architecture That Ships
Your tech stack (React 19, FastAPI, PostgreSQL) is optimized for server-centric architecture. Lean into it:
- Monorepo: Shared types eliminate contract bugs
- Domain-driven APIs: Business logic lives in endpoints, not scattered across middleware
- Server state first: Render from database, mutate through verified actions
- Multi-tenancy from day one: Tenant ID in every query, enforced at the database layer
- Token refresh middleware: Silent auth renewal prevents mid-session failures
This is how CitizenApp scaled from prototype to handling millions of AI feature requests. Start here, deviate only when you've proven the limitations.
Top comments (0)