FastAPI and Flutter are a strong pairing — both are typed, both have excellent tooling, and FastAPI's OpenAPI output means you can generate a type-safe Flutter client automatically. Here's the architecture I use on production projects.
Contract-First Development
The single most impactful practice is defining Pydantic schemas before writing implementation. These become the contract between backend and mobile:
from pydantic import BaseModel
from datetime import datetime
class TaskCreate(BaseModel):
title: str
description: str | None = None
class TaskResponse(BaseModel):
id: str
title: str
description: str | None
status: TaskStatus
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}FastAPI generates OpenAPI JSON automatically at /openapi.json. From that, a type-safe Dart client is generated — no hand-written API models on the Flutter side.
Project Structure
Routes stay thin — business logic lives in services/. When the API changes, regenerate the client and TypeScript errors surface breaking changes before you touch a widget.
JWT Authentication
Short-lived access tokens (15 min) with refresh tokens (30 days) is the right balance for mobile. The critical piece on the Flutter side is the auth interceptor that silently refreshes before the user notices a 401:
class AuthInterceptor extends Interceptor {
final TokenStorage _storage;
final Dio _dio;
bool _refreshing = false;
final _queue = <({RequestOptions options, ErrorInterceptorHandler handler})>[];
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) return handler.next(err);
if (_refreshing) {
_queue.add((options: err.requestOptions, handler: handler));
return;
}
_refreshing = true;
try {
final refreshToken = await _storage.getRefreshToken();
final res = await _dio.post('/auth/refresh',
data: {'refresh_token': refreshToken});
await _storage.saveTokens(
access: res.data['access_token'],
refresh: res.data['refresh_token'],
);
// Retry original request and all queued ones
handler.resolve(await _dio.fetch(err.requestOptions));
for (final q in _queue) {
q.handler.resolve(await _dio.fetch(q.options));
}
} catch (_) {
await _storage.clear();
} finally {
_refreshing = false;
_queue.clear();
}
}
}The queue pattern is essential — when multiple requests fail simultaneously during a token expiry, only one refresh call is made and all queued requests retry with the new token.
Consistent Error Handling
Mobile clients need to parse errors programmatically. Standardize the error shape on the backend:
class APIError(Exception):
def __init__(self, code: str, message: str, status: int = 400):
self.code = code
self.message = message
self.status = status
@app.exception_handler(APIError)
async def api_error_handler(request, exc: APIError):
return JSONResponse(
status_code=exc.status,
content={"error": {"code": exc.code, "message": exc.message}},
)Your Flutter client always knows error.code and error.message exist:
class ApiException implements Exception {
final String code;
final String message;
final int statusCode;
}
try {
await taskService.createTask(title);
} on ApiException catch (e) {
if (e.code == 'QUOTA_EXCEEDED') showUpgradeDialog();
else showSnackBar(e.message);
}This removes defensive null-checking on every response and lets the UI branch on error codes rather than parsing raw HTTP statuses.
Real-Time Updates
For live data, WebSockets with a connection manager pattern:
from fastapi import WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.connections: dict[str, WebSocket] = {}
async def connect(self, user_id: str, ws: WebSocket):
await ws.accept()
self.connections[user_id] = ws
async def send(self, user_id: str, data: dict):
if ws := self.connections.get(user_id):
await ws.send_json(data)On the Flutter side, WebSocket reconnection with exponential backoff is handled in a dedicated service — screens subscribe to a stream and never touch the socket directly.
The Workflow
- Define Pydantic schemas in FastAPI
- Start backend (
uvicornwith reload) - Generate the Dart client from OpenAPI
- Build Flutter features against the typed client
When the API changes, regenerate — compiler errors in your Dart code immediately surface breaking changes before you touch a single widget. This removes an entire class of integration bugs. Keep the generator in a Makefile or justfile and run it in CI to catch schema drift early.