Skip to content

Architecture

This document explains how FastReact is structured and the reasoning behind key architectural decisions.

1. The Monorepo Structure

FastReact has four parts:

fastreact/
├── backend/          # FastAPI + Python (your API)
├── frontend/         # React + TypeScript (admin dashboard)
├── landing/          # React (marketing site)
└── backend/db/       # PostgreSQL + Sqitch (database migrations)

Why a monorepo?

FastReact uses a monorepo because it's a tightly coupled fullstack application where the backend and frontend are designed to work together. When the backend API changes, the frontend needs to change with it - keeping them in separate repositories would mean managing dependencies, versioning, and synchronization across repos. A monorepo allows atomic commits that update the database schema, backend logic, and frontend UI simultaneously, ensuring the entire system stays in sync. This simplifies development with a single clone and unified tooling.

Design Philosophy: Minimal Dependencies, Maximum Flexibility

FastReact intentionally avoids many popular libraries and frameworks that other starter kits include. Instead of bundling heavy abstractions, it sticks to proven, essential tools with minimal dependencies.

Why stay lean? Adding a library later is straightforward - removing one that's baked into the starter kit is painful. As a starter kit, FastReact's job is to provide a solid foundation, not to make architectural decisions for you. This approach makes the codebase highly customizable. Build exactly what you need, add libraries as your requirements become clear, and maintain full control over your architecture.


2. End-to-End Request Flow

Let's trace what happens when a user creates a project in your SaaS app. (This example follows the Adding a Feature tutorial, where you build a project management feature.)

High-Level Flow

sequenceDiagram
    participant User
    participant Frontend as Frontend<br/>(React)
    participant Backend as Backend<br/>(FastAPI)

    User->>Frontend: Fill form & click "Create Project"
    Frontend->>Backend: POST /api/projects
    Backend->>Backend: Validate, auth, save to DB
    Backend-->>Frontend: ProjectResponse (JSON)
    Frontend->>Frontend: Update UI
    Frontend-->>User: Show success

The flow:

  1. User fills out a form and clicks "Create Project"
  2. Frontend sends API request with project data
  3. Backend validates, authenticates, and saves to database
  4. Response flows back: backend → frontend → user sees success

That's the high-level flow. Now let's see how each piece is built.


3. Backend: Layered Architecture

The backend is where all the heavy lifting happens. External services like Stripe, SendGrid, and Google OAuth integrate here. The frontend stays thin - it's purely a presentation layer that talks to the backend API.

The backend separates concerns into layers. Here's how a request flows through them:

Backend Request Flow

sequenceDiagram
    participant Route as Route Layer<br/>(HTTP)
    participant Service as Service Layer<br/>(Business Logic)
    participant Repo as Repository<br/>(Data Access)
    participant DB as PostgreSQL

    Route->>Route: Validate request & auth
    Route->>Service: create_project(org_id, user_id, data)
    Service->>Service: Check quotas & validate
    Service->>Repo: create_project(org_id, user_id, data)
    Repo->>DB: INSERT INTO project...
    DB-->>Repo: Return project ID
    Repo->>DB: SELECT * FROM project WHERE id = ?
    DB-->>Repo: Return project data
    Repo-->>Service: Project entity
    Service-->>Route: Project entity
    Route-->>Route: Convert to JSON response

Each layer has a specific job:

  • Route - Handles HTTP (validation, auth, response formatting)
  • Service - Business logic (quotas, permissions, workflows)
  • Repository - Database access (SQL queries)
  • Database - Data storage

Directory Structure

app/
├── main.py              # Starts everything
├── config/              # Settings & dependency injection
├── api/route/           # HTTP endpoints
├── service/             # Business logic
├── data/repo/           # Database queries
├── model/               # Request/response shapes
└── util/                # Auth, email, etc.

Keep Layers Separated

Don't leak concerns between layers. Services shouldn't know about HTTP status codes or request objects. Repositories shouldn't contain business logic.

Bad: the service returns an HTTP exception:

# Wrong: the service layer shouldn't know about HTTP
async def create_user(self, email: str):
    if self.user_repo.exists(email):
        raise HTTPException(status_code=409, detail="User exists")

Good: the service throws a domain exception, the route handles HTTP:

# Right: the service throws a domain exception
async def create_user(self, email: str):
    if self.user_repo.exists(email):
        raise UserAlreadyExistsException(email)

# Right: the route converts it to HTTP
@router.post("/users")
async def create_user_route(data: UserCreate):
    try:
        return await user_service.create_user(data.email)
    except UserAlreadyExistsException as e:
        raise HTTPException(status_code=409, detail=str(e))

Why raw SQL instead of an ORM?

ORMs add complexity. You learn the ORM's query language, debug what SQL it generates, then eventually write raw SQL anyway for performance. With raw SQL in repositories, you see exactly what runs and optimize directly.

Raw SQL is also easier for LLMs to generate and reason about, making AI-assisted development smoother.

Beyond technical considerations, this choice aligns with FastReact's design philosophy of staying lean. Adding an ORM later is straightforward when your project needs it - removing one that's baked into the starter kit is painful. You maintain full control over data access patterns and can choose SQLAlchemy, Prisma, or any other tool based on your actual requirements.

Why dependency injection?

Dependency injection eliminates repetitive boilerplate and centralizes configuration. Instead of manually constructing dependencies in every route, they're wired up once and injected automatically.

In FastReact, all objects are wired up in one place (app/config/container.py):

# Define everything once
project_repo = providers.Singleton(ProjectRepo, db_config=db_config)
project_service = providers.Singleton(ProjectService, project_repo=project_repo)

Then use them anywhere:

async def create_project(
    project_service: ProjectService = Depends()  # Injected automatically
):
    ...

This centralization provides several benefits. Object lifecycles (singleton vs factory) are explicit and visible in one file - no hunting through the codebase to determine if a service creates new instances or reuses one. Configuration changes propagate automatically without touching route code. Testing becomes straightforward by swapping implementations in the container rather than modifying dozens of files.


4. Database: Multi-Tenant PostgreSQL

All data is scoped to an organization (the tenant boundary), so the schema serves both individual users and teams without changing. Migrations are plain SQL managed with Sqitch, no ORM. The mode (b2c / b2b) is set by FS_MODE and changes only application logic, not the schema.

See Database for the schema, db_config, and the Sqitch workflow, and Multi-Tenancy for the organization, role, and invitation model.


5. Frontend: Type-Safe React

The frontend is a React SPA that stays thin by delegating all business logic to the backend. It uses React Router v7, with React hooks and Context for state, and maintains type safety through an auto-generated API client.

Directory structure:

app/
├── routes/           # Route modules (React Router v7)
├── components/       # Reusable UI components
├── contexts/         # App-wide providers (auth, config)
├── hooks/            # Custom React hooks
├── lib/
│   ├── api/gen/      # Auto-generated TypeScript API client (Orval)
│   ├── auth/         # Session management (auth context)
│   └── utils/        # Helper functions
└── root.tsx          # Root layout

Key features:

  • Route-based authentication: protected routes check for a valid session via the auth context
  • Auto-generated API client: TypeScript types generated from the OpenAPI spec ensure compile-time safety
  • React Context + hooks: global state (auth, config) via Context; local state via hooks
  • TailwindCSS + DaisyUI: utility-first styling with pre-built component themes

Rendering model: app and landing

FastReact ships two React Router projects that render differently on purpose:

App (frontend/) Landing (landing/)
Mode SPA (ssr: false) SSG (ssr: false + prerender: true)
Build output Static files in build/client with an index.html fallback Static HTML per route in build/client, with real content
Why Lives behind auth, so there is nothing for crawlers to index. Client-side rendering keeps session handling simple. Marketing pages live or die by SEO. Crawlers get complete HTML without running JavaScript.

Both projects build to plain static files, so the only server in any deployment is the FastAPI backend. There is no Node tier to run, scale, or pay for: react-router build produces build/client, and that directory is the whole deployment. Deep links like /settings still work on a static host because the deploy configs shipped with the kit (vercel.json, staticwebapp.config.json) rewrite unknown paths to the SPA fallback.

Prerendering freezes the landing's content at build time. Editing marketing copy means rebuild and redeploy, and VITE_* variables are baked in during the build (set them in CI, not in your host's runtime settings).

If your landing outgrows static. The landing's routes are server-rendered at build time, so the code stays fully server-renderable. If you later need per-request rendering (personalization, instant-publish content), set ssr: true in landing/react-router.config.ts, add @react-router/serve back as a dependency, and run react-router-serve ./build/server/index.js. That is a config change, not a rewrite. Forms like the newsletter signup never need it: point them at a FastAPI endpoint from the client.

Auto-generated API client

The frontend's TypeScript API client is generated from the backend's OpenAPI spec, so a backend change surfaces as a compile-time error in the frontend. See Type-Safe API Client (Orval).

Data loading: clientLoader vs useEffect

Routes fetch their data in a clientLoader, not in a useEffect. The loader starts as soon as you navigate, so the page appears immediately instead of mounting empty and then filling itself in.

There are two flavours, and one exception.

Route type Pattern Example to copy
Lists, tables, dashboards, detail views clientLoader, streamed (return the promise, do not await it) routes/protected/notes.tsx
Editable forms clientLoader, awaited (return the finished data) routes/protected/settings.tsx
Polling, WebSockets, upload progress useEffect in the component, with cleanup routes/protected/admin-health.tsx

Streamed means the loader returns a promise it never awaited:

export async function clientLoader() {
    return { notes: listNotes() }; // note: no await
}

The route unwraps it with <Suspense> + <Await>, which puts the loading, loaded and failed states in one place. You do not write a loading flag:

<Suspense fallback={<NotesGridSkeleton />}>
    <Await resolve={notes} errorElement={<p>Failed to load notes.</p>}>
        {(notes) => <NotesGrid notes={notes} />}
    </Await>
</Suspense>

Awaited is for forms, and the reason is dirty-detection. When the loader hands over finished data, loaderData is the saved state, so "does this form have unsaved changes?" is just a comparison against it:

const [theme, setTheme] = useState(data.theme); // seeded once, then follows the user
const hasChanges = theme !== data.theme;

Do it the other way and you end up maintaining a second originalTheme copy by hand, and resyncing it after every save. (With react-hook-form, seed defaultValues from the loader and let its own isDirty do this for you.)

Two rules that keep this working:

  1. Never copy loader data into useState on a read-only route. The copy goes stale the moment the loader re-runs, and then two places disagree about the truth. Read loaderData directly. Forms are the exception, and only for the fields being edited, as above.
  2. Refresh by re-running the loader, not by refetching. After a mutation, call revalidate():
await deleteNote(id);
await revalidate(); // re-runs the loader, which refetches the list

revalidate() re-runs every loader on the active route, so there is nothing to name and nothing to keep in sync.

Revalidation keeps your data on screen

React Router re-runs loaders inside a transition, so the current UI stays put until the new data is ready. It does not fall back to the <Suspense> skeleton. That is why deleting a row updates the list in place rather than flashing a spinner, and it is what makes polling through the loader viable (see admin-health.tsx).

Why not useEffect? It only runs after the component mounts, so navigation completes, the page renders empty, and then the request starts. You also hand-roll loading and error state, refetching when a route param changes, and cancelling requests that a newer one superseded. Loaders do all of that for you, and fetch-on-mount is what the react-hooks/set-state-in-effect lint rule is warning you about.

Keep useEffect for what is genuinely tied to the component's lifetime: a timer, a socket, an event listener you have to remove.

Background reading: when to use load functions and onMount and load functions vs onMount. They are written against SvelteKit, but the decision is the same one; clientLoader is React Router's +page.ts.


6. Authentication & Security

Session-based authentication

We use session cookies (not JWT tokens):

  1. User logs in → Backend creates session in database
  2. Backend sends HTTP-only cookie with session ID
  3. Every API call includes this cookie automatically
  4. Backend checks: "Is this session valid?" before responding

Why session cookies instead of JWT?

  • HTTP-only cookies can't be stolen by JavaScript (XSS protection)
  • Server controls sessions = instant logout
  • Simpler frontend code = no token refresh logic
  • Built-in CSRF protection with SameSite cookies

Sessions expire after 24 hours (configurable). The backend stores hashed session tokens and compares them on each request.

Role-based access control

Four roles, ordered by precedence (readonly < member < org_admin < sys_admin):

  • readonly - View-only access
  • member - Basic user (can use the app)
  • org_admin - Manage organization (invite users, change settings)
  • sys_admin - Full system access (manage all orgs, see analytics)

See Authentication and Security for the full model. Protect routes with role checks:

@router.get("/admin/users")
async def list_users(
    current_user: CurrentUser = Depends(min_role_required(Role.SYSTEM_ADMIN))
):
    # Only sys_admins can reach this

Protected routes check authentication on the frontend via the auth context:

// app/routes/protected/layout.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "~/lib/auth/auth-context";

export default function ProtectedLayout() {
  const { isAuthenticated } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!isAuthenticated) navigate("/login"); // redirect if not authenticated
  }, [isAuthenticated]);
  // ... render protected content
}

7. Design Decisions

Why file name suffixes like user_service.py?

Files are named service/user_service.py instead of service/user.py. The suffix appears redundant since the folder already indicates the layer, but it solves a practical problem.

Without suffixes:

user.py | user.py | user.py | user.py

With suffixes:

user_route.py | user_service.py | user_model.py | user_repo.py

When multiple files are open, IDE tabs show filenames, not full paths. Without suffixes, every tab displays user.py - making navigation difficult. The suffix also improves search: typing "user_service" immediately finds the right file instead of filtering through four different user.py files across different folders.

Where should imports go?

Put imports at the top of the file by default. Python caches imported modules, so top-level imports cost nothing at runtime, and keeping them together makes a file's dependencies obvious at a glance.

Move an import inside a function only for a specific reason:

  • Optional dependencies: a feature relying on a package not every install includes. An inline import keeps the module importable when the package is absent, failing only if the feature is actually used. The email provider factory does this: it imports the Azure, SendGrid, or Resend client only when that provider is selected, so you don't need all three SDKs installed.
  • Breaking a circular import: when two modules need each other at import time, a deferred import inside the function that needs it sidesteps the cycle.
  • Smoke test isolation: keeping a heavy or environment-dependent import out of module load so a smoke test can exercise the rest of the module without it.

Why Factory vs Singleton in dependency injection?

Singleton = one instance for the whole app:

# Same UserService instance every time
user_service = providers.Singleton(UserService, user_repo=user_repo)

Factory = a fresh instance every time it's injected:

# Fresh instance per injection
report_builder = providers.Factory(ReportBuilder, plan_repo=plan_repo)

FastReact's container registers everything as a Singleton, deliberately. Repos and services are stateless: they hold only references to other singletons and read-only config, so there is no per-request state to isolate. The one stateful component, the database connection pool, lives inside the db_config singleton precisely so that every repo shares one pool instead of each opening its own connections. The OpenAI client is similar: it keeps persistent HTTP connections open, and sharing one instance reuses them instead of reconnecting on every request.

Use Factory when you add a component that holds per-request state (a unit-of-work object, a mutable builder, anything unsafe to share across concurrent requests). When you add an ordinary repo or service, register it as a Singleton like everything else in the container.

How does error handling work?

FastReact uses domain exceptions that inherit from BaseAppException. Each exception knows its own HTTP status code, error code, and message format. A global error handler middleware automatically converts these to JSON responses.

Services throw domain exceptions:

from app.exception.common_exception import ResourceNotFound

async def get_user(self, user_id: int):
    user = await self.user_repo.get_by_id(user_id)
    if not user:
        raise ResourceNotFound(resource="user", resource_id=user_id)
    return user

Routes don't need try/catch - exceptions bubble up to middleware:

@router.get("/{user_id}")
async def get_user_route(user_id: int, user_service: UserService = Depends()):
    # No try/catch needed - middleware handles it
    return await user_service.get_user(user_id)

Middleware automatically converts to HTTP response:

The global error handler in app/api/middleware/error_handler.py catches all BaseAppException instances and returns structured JSON responses with the appropriate status code, error code, message, and details.

See Section 3 for why services shouldn't know about HTTP - they might be called from routes, background jobs, CLI scripts, or tests.



Next Steps: