Routers, project layout and API versioning

Split a growing application into modules with APIRouter, share prefixes and dependencies, and introduce /api/v1 without breaking existing clients.

APIRouter

# app/routers/items.py
from fastapi import APIRouter, Depends
from ..deps import get_current_user
from ..schemas import Item, ItemOut

router = APIRouter(
    prefix="/items",
    tags=["items"],
    dependencies=[Depends(get_current_user)],
    responses={404: {"description": "Not found"}},
)

@router.get("", response_model=list[ItemOut])
def list_items():
    return []

@router.get("/{item_id}", response_model=ItemOut)
def get_item(item_id: int):
    return {"id": item_id, "name": "widget", "price": 9.99}
# app/main.py
from fastapi import FastAPI
from .routers import items, users

app = FastAPI(title="Shop API", version="1.0.0")
app.include_router(items.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
  • The effective path is the router prefix plus the include prefix plus the route path.
  • Router-level dependencies run for every route in that router, which is how you require authentication for a whole area.
  • An empty route path ("") keeps the collection endpoint at the prefix without a trailing slash.

A layout that survives growth

app/
  main.py            # creates FastAPI, includes routers, adds middleware
  config.py          # Settings via pydantic-settings
  db.py              # engine, session factory, get_db dependency
  deps.py            # shared dependencies: current user, pagination
  models/            # SQLAlchemy tables
  schemas/           # Pydantic request/response models
  routers/
    items.py
    users.py
  services/          # business logic, no FastAPI imports
tests/
  conftest.py
  test_items.py

The rule that keeps this maintainable: routers translate HTTP to function calls, services hold the logic, and services do not import FastAPI. That makes the important code testable without a client.

Versioning

v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")

@v1.get("/items")
def items_v1():
    return [{"id": 1, "title": "widget"}]          # legacy field name

@v2.get("/items")
def items_v2():
    return [{"id": 1, "name": "widget"}]           # renamed in v2

app.include_router(v1)
app.include_router(v2)

Prefix versioning is the least disruptive option: old clients keep working, both versions appear in /docs as separate tag groups, and you retire v1 when the traffic graph reaches zero.

⚠️
Do not include the same router twice with the same prefix — FastAPI accepts it and the first match wins, which produces routes that exist in OpenAPI but are unreachable. Check app.routes when a path behaves unexpectedly.

FAQ

Trailing slash or not?
FastAPI redirects /items/ to /items by default, but a 307 redirect loses the body on some clients. Define paths explicitly and be consistent, especially for POST endpoints behind a proxy.
Should I put validation logic in the router?
Keep request shape in Pydantic schemas and business rules in services. A router that grows past a couple of dozen lines of logic is usually a service waiting to be extracted.

Your first FastAPI app Deployment: uvicorn workers, Docker and reverse proxies

Last refreshed 2026-09-18.