A single main.py stops scaling after a few dozen endpoints; routers give you modules a team can work in.
An APIRouter is a mini-app: it has the same .get, .post and other decorators, and is attached to the real app with app.include_router(router). Give each feature its own module and router:
app/
main.py # creates FastAPI(), includes routers
dependencies.py # get_session, get_current_user...
models.py # Pydantic / SQLModel models
routers/
items.py
users.pyA router can carry configuration for all its routes: APIRouter(prefix="/items", tags=["items"], dependencies=[Depends(get_current_user)], responses={404: {"description": "Not found"}}). Inside it, paths are relative: @router.get("/{item_id}") serves /items/{item_id}.
The same options can be passed when including instead: app.include_router(admin.router, prefix="/admin", dependencies=[Depends(require_admin)]). That's handy when you include a router you don't own, or mount one router under two prefixes. Settings combine: prefixes concatenate, and tags and dependencies accumulate from app to router to route.
Routers can include other routers, so API versioning is just a v1 = APIRouter(prefix="/v1") that includes the feature routers, and the app includes v1.
Two habits keep larger codebases healthy. Keep imports flowing one way: routers import from dependencies.py and models.py, and main.py imports the routers; circular imports usually mean shared code lives in the wrong module. And keep handlers thin: parse input, call a plain service function, return a model. Business logic in plain functions can be tested without HTTP at all.
Example
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
# app/dependencies.py
def require_admin(x_role: Annotated[str, Header()] = "user"):
if x_role != "admin":
raise HTTPException(status_code=403, detail="Admins only")
# app/routers/items.py
items = APIRouter(prefix="/items", tags=["items"])
@items.get("/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}
# app/routers/admin.py
admin = APIRouter()
@admin.post("/reindex")
def reindex():
return {"started": True}
# app/main.py
app = FastAPI()
app.include_router(items)
app.include_router(admin, prefix="/admin", dependencies=[Depends(require_admin)])
c = TestClient(app)
print(c.get("/items/3").json()) # {'item_id': 3}
print(c.post("/admin/reindex").status_code) # 403
print(c.post("/admin/reindex", headers={"X-Role": "admin"}).json()) # {'started': True}Remember
- APIRouter has the same decorators as the app; include it with app.include_router
- prefix, tags and dependencies can be set on the router or at include time
- Prefixes concatenate and dependencies accumulate down the tree
- One router per feature module; keep imports flowing one way
Watch out for
- Ending a prefix with a slash (prefix='/items/') and getting paths like /items//{id}.