
FastAPI + React — why I chose this stack for Dogwalk
Context
After defining the Dogwalk MVP, the next critical decision came: which stack to use? I knew I wanted something modern, productive, and that wouldn’t make me reinvent the wheel. But I also didn’t want to bet on tech hype that would leave me stranded 6 months down the line.
The decision process took a week — and involved more discarded prototypes than I’d like to admit.
The candidates
Before choosing, I listed the stacks I considered viable:
| Stack | Backend | Frontend | Mobile | Productivity |
|---|---|---|---|---|
| A → FastAPI + React | Python 3.11+ | React + Vite | PWA | High |
| B → Next.js fullstack | Node/TS | Next.js | PWA | Medium |
| C → Django + HTMX | Python | HTML/JS | PWA | High |
| D → Flask + Alpine | Python | Alpine.js | PWA | Medium |
| E → Spring Boot + Angular | Java/Kotlin | Angular | Native | Low |
In the early days, option B (Next.js fullstack) seemed the most attractive — one framework, TypeScript end-to-end, Vercel handling deploy. But as I dug deeper, problems emerged.
Why not Next.js?
I love JS/TS, but for this specific project, Next.js brought more questions than answers:
# Dilemma that made me jump from Next to FastAPI
# Scenario: I need to run an async task queue
# In Next.js (API routes):
# - 10s timeout on serverless functions (Vercel)
# - No native worker for background jobs
# - Solution: BullMQ + Redis + separate worker = 3 services
# In FastAPI:
# - Native BackgroundTasks
# - Celery/ARQ if you need a queue
# - WebSocket for real-time
# - Solution: 1 service + 1 optional worker
The serverless functions timeout was the biggest limiter. Dogwalk needs:
- Photo upload with resizing
- Optimized route calculation for dog walkers
- Batch push notifications
- Payment processing with reconciliation
All that in serverless with 10s timeout? It could be worked around, but with workarounds that added unnecessary complexity.
The decision: FastAPI + React
The final choice wasn’t emotional — it was a spreadsheet.
criterios = {
"performance": {"peso": 3, "fastapi": 9, "next": 8, "django": 6},
"ecosystem": {"peso": 3, "fastapi": 8, "next": 9, "django": 9},
"productivity": {"peso": 4, "fastapi": 9, "next": 7, "django": 8},
"scalability": {"peso": 2, "fastapi": 8, "next": 6, "django": 7},
"infra_cost": {"peso": 3, "fastapi": 8, "next": 5, "django": 8},
"maturity": {"peso": 2, "fastapi": 7, "next": 7, "django": 10},
}
def calcular_nota(criterios, stack):
total = sum(
v["peso"] * v[stack]
for k, v in criterios.items()
)
return total / sum(v["peso"] for v in criterios.values())
for stack in ["fastapi", "next", "django"]:
print(f"{stack}: {calcular_nota(criterios, stack):.2f}")
# → fastapi: 8.35
# → next: 7.06
# → django: 7.82
FastAPI won by a slim margin, but it won consistently — it was first or second in every criterion, with no serious weak point.
Why React and not something else on the frontend
With the backend decided, came the frontend choice. Here were the options:
| Framework | Advantages | Disadvantages |
|---|---|---|
| React + Vite | Mature ecosystem, components, PWA | Large bundle, extra decisions |
| Vue 3 + Nuxt | More opinionated, reactive | Fewer available devs |
| Svelte | Small bundle, performant | New ecosystem |
| Alpine + SSR | Simple, no build | State complexity |
| HTMX | Zero JS, pure HTML | Limited in complex UI |
I chose React because:
- Maps ecosystem — Leaflet, Mapbox, Google Maps have mature React components, essential for Dogwalk
- PWA with no effort — Vite + vite-plugin-pwa delivers PWA in 5 minutes
- TypeScript — Typing saves the day in projects with many entities (provider, owner, service, payment, review)
- Market — If I ever need help, React devs are easier to find
The architecture I set up
With the stack decided, I drew the project architecture:
dogwalk/
├── api/ # FastAPI backend
│ ├── app/
│ │ ├── main.py # Entry point + middleware
│ │ ├── config.py # Settings via pydantic-settings
│ │ ├── models/ # SQLAlchemy + Pydantic models
│ │ ├── routers/ # REST endpoints
│ │ ├── services/ # Business logic
│ │ └── workers/ # Async tasks
│ ├── alembic/ # Migrations
│ └── tests/ # Tests with pytest
│
├── web/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable components
│ │ ├── pages/ # Application pages
│ │ ├── hooks/ # Custom hooks
│ │ ├── services/ # API client (axios)
│ │ └── stores/ # Global state (zustand)
│ ├── public/ # Static assets
│ └── tests/ # Tests with vitest
│
└── infra/ # Docker + deploy
├── docker-compose.yml
├── Dockerfile.api
├── Dockerfile.web
└── nginx.conf
One decision I made a point of making early: clear separation between api and web, in different folders, each with its own package.json/pyproject.toml. No monorepo with everything mixed together. Each part can be developed, tested, and deployed independently.
Environment setup: what worked
After some trial and error, the ideal setup looked like this:
# Backend
cd api
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn sqlalchemy asyncpg pydantic-settings
# Frontend
cd web
npm create vite@latest . -- --template react-ts
npm install react-router-dom zustand axios leaflet
npm install -D @types/leaflet tailwindcss postcss autoprefixer
The secret I discovered: pydantic-settings with .env file. This saved countless config headaches:
# api/app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "Dogwalk API"
debug: bool = False
database_url: str = "postgresql+asyncpg://localhost:5432/dogwalk"
secret_key: str = "change-me-in-production"
cors_origins: list[str] = ["http://localhost:5173"]
sentry_dsn: str | None = None
cloudflare_r2_endpoint: str | None = None
maps_api_key: str | None = None
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@lru_cache()
def get_settings():
return Settings()
// web/src/services/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// JWT token interceptor
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
The first endpoints
With the setup working, I wrote the first real endpoints:
# api/app/routers/prestadores.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Prestador
from app.schemas import PrestadorOut, PrestadorCreate
router = APIRouter(prefix="/prestadores", tags=["prestadores"])
@router.get("/", response_model=list[PrestadorOut])
async def listar_prestadores(
lat: float | None = None,
lng: float | None = None,
raio_km: float = 5.0,
servico: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""List providers with optional geolocation filter"""
query = "SELECT * FROM prestadores WHERE 1=1"
params = {}
if lat and lng:
query += """ AND ST_DWithin(
ST_MakePoint(:lng, :lat)::geography,
localizacao::geography,
:raio
)"""
params["lat"] = lat
params["lng"] = lng
params["raio"] = raio_km * 1000
if servico:
query += " AND :servico = ANY(servicos)"
params["servico"] = servico
result = await db.execute(query, params)
return result.scalars().all()
@router.get("/{prestador_id}", response_model=PrestadorOut)
async def detalhe_prestador(
prestador_id: int,
db: AsyncSession = Depends(get_db),
):
prestador = await db.get(Prestador, prestador_id)
if not prestador:
raise HTTPException(status_code=404, detail="Provider not found")
return prestador
FastAPI’s async really shone here — with asyncpg and AsyncSession, queries run without blocking the event loop, and the API can handle hundreds of concurrent requests even on a small instance.
What I learned
1. Unified stack (JS/TS fullstack) looks attractive but comes at a price
Next.js is beautiful for landing pages and blogs. For an app with background jobs, WebSocket, and server-side processing, you end up setting up the same infra you’d have with FastAPI — just in JS.
2. Front/backend separation isn’t dogma — it’s pragmatism
Having the backend in Python and the frontend in React means each side uses the right libraries for its domain. Python has SQLAlchemy, Alembic, Pydantic, Celery/ARQ for the backend. React has UI ecosystem, maps, state. No one has to make concessions.
3. Pydantic v2 is one of the best Python packages around
Validation, serialization, automatic documentation (OpenAPI), settings management, type hints — Pydantic v2 does all this with native performance (Rust). It’s the kind of library that improves every piece of code it touches.
4. Config via .env + pydantic-settings saves lives
In the beginning, I had configs scattered as constants in the code. On one of the first deploys, a hardcoded DATABASE_URL leaked into the repository. Since I migrated to .env, I’ve never had this problem again.
5. TypeScript on the frontend + Pydantic on the backend = consistency
I create the Pydantic schemas on the backend and the corresponding TypeScript types on the frontend. It’s not automatic (one day I’ll build a generator), but the discipline of keeping both in sync has already prevented at least 5 type bugs in API calls.
The stack numbers
| Aspect | Result |
|---|---|
| Frameworks considered | 5 |
| Days of decision | 7 |
| Endpoints created in first sprint | 12 |
| React components in first sprint | 8 |
| Tests passing | 47 |
| ms per request (average) | 12 |
| Lines of configuration | ~300 |
TL;DR: I chose FastAPI + React after a week of analysis comparing 5 stacks against objective criteria. FastAPI won for the combination of performance, productivity, and Python ecosystem. React won for the maturity of maps and PWA ecosystem. The clear separation between frontend and backend lets each side use the right tools without compromise.