FastAPI is great for building APIs where every route is known up front. Define your paths, decorate your handlers, and you're done. But not every application works that way.
Think of a URL as a tree. Each segment is a branch, and you walk it from left to right until you reach a leaf. In a multi-tenant app, the first segment might be a tenant slug — you need to look it up in the database before you even know what routes are available under it. The next segment might be a project, and the project's type determines what comes after that. The URL's structure isn't static — it unfolds as you resolve it.
FastAPI doesn't have an answer for this. Its routing is decided at startup: you declare patterns, and the framework matches them. There's no built-in way to say "resolve this prefix, then hand the rest of the path to something that knows what to do with it."
I ran into this problem, tried making things work within FastAPI's static routes, then spent a while building an increasingly complicated workaround before realizing the core idea was simple enough to be its own thing.
My first attempt was a custom filter system layered on top of FastAPI. A filter would claim a URL segment, attach state to the request, and register further filters to handle the rest of the path. Here's a simplified version of what the top-level filter looked like:
class ProjectFilter(Filter):
async def try_section_match(self, section, context):
project = await Project.get_by_slug(section, db)
if project is None:
return False
context.request.state.project = project
context.request.state.branch = f"{project.path}/"
context.clear_filters()
if project.type == "EXTENDED":
context.add_filter(extended_filter)
else:
context.add_filter(routes_filter)
context.add_filter(matches_filter)
context.add_filter(printing_filter)
context.add_filter(date_filter)
return True
Each of those filters was essentially its own mini-router, declaring handlers with decorators:
@filter.get("matches")
async def get_matches(request, project):
...
@filter.post("matches/int:id")
async def save_match(request, id, payload, project):
...
It worked. But I was maintaining a parallel routing system — filters calling filters, manually wiring up state, conditionally building handler chains depending on what the database returned. The filter that resolved a project had to know about every sub-filter and register them in the right order. Adding context meant mutating request.state by hand. The actual routing logic was tangled up with setup and bookkeeping.
The thing is, the individual filters were fine. The decorator syntax was clean, the handlers were simple. The problem was the glue holding it all together.
Once I stopped thinking about filters and went back to what I was actually trying to do, the idea was pretty straightforward. I wanted a route handler that could:
In other words, a handler that doesn't return a response — it returns something that knows how to produce one.
from fastapi_resolve import Router, Deferred
router = Router()
@router.get("/slug:project_slug/*")
async def resolve_project(project_slug: str) -> ProjectBranch | None:
project = await Project.get_by_slug(project_slug, db)
if project:
return ProjectBranch(project)
If the slug matches a project, the handler returns a ProjectBranch — a Deferred subclass that holds the project and declares its own routes. If it doesn't match, it returns None and the router moves on. No state mutation, no filter registration, no manual wiring.
from fastapi_resolve import Deferred, Router
from contents.matches import router as matches_router
from contents.printing import router as printing_router
class ProjectBranch(Deferred):
router = Router()
matches = matches_router
printing = printing_router
def __init__(self, project: Project):
super().__init__()
self.project = project
def context(self):
return self.project
@router.get("")
def home(self, request):
return templates.TemplateResponse(
"project.html",
{"request": request, "project": self.project}
)
The same module imports, the same separation of concerns — but the Deferred class is the glue now, and it's declarative. The routers are class attributes. The context flows through context() instead of being manually attached to request.state. The conditional branching that used to live in a filter's setup logic is just __init__.
The key design choice is that a handler returns an object. Not a response, not a status code — an instance that carries context and declares routes. That might seem like an odd pattern for Python, but it solves several problems at once.
First, it keeps the resolver and the routes together. A Deferred subclass is a self-contained scope — it holds the resolved context as instance properties, and its routes can access that context through self. External routers attached as class attributes get it through the context() method. Either way, the context flows naturally through the object rather than being stuffed into request.state and fished back out later.
The context() method is the decoupling seam. By default it returns self, but you can override it to return a domain object — a Project, a Tenant, whatever your routes actually care about. That means your route modules can import the domain type directly and declare deferred: Project as a parameter. They don't need to know about ProjectBranch or any routing machinery. No circular imports, no coupling to the routing layer.
Then there's the commit semantics. Once a handler returns a Deferred instance, the router is committed to that branch. If nothing inside the Deferred matches the remaining path, you get a 404 — the router doesn't backtrack and try other top-level routes. This is deliberate. If a slug resolved to a valid project, the URL belongs to that project. Getting a 404 within its scope is correct behavior, not a signal to keep searching.
Returning None is the escape hatch. If a handler can't resolve its segment — the slug doesn't match anything in the database — it returns None and the router moves on to try the next matching pattern. The decision point is clean: either you own this path or you don't.
Because the handler returns an object, the decision about which object is just Python. Different project types can return different Deferred subclasses, each with their own routes:
@router.get("/slug:project_slug/*")
async def resolve_project(project_slug: str) -> Deferred | None:
project = await Project.get_by_slug(project_slug, db)
if project is None:
return None
if project.type == "EXTENDED":
return ExtendedProjectBranch(project)
return ProjectBranch(project)
No conditional filter chains, no flag-driven branching inside a single handler — just different classes with different routes.
It also composes vertically. A Deferred handler can itself return another Deferred, creating nested scopes. A tenant resolver returns a TenantBranch, and one of its routes resolves a project slug and returns a ProjectBranch:
class TenantBranch(Deferred):
router = Router()
def __init__(self, tenant):
super().__init__()
self.tenant = tenant
@router.get("slug:project_slug/*")
async def resolve_project(self, project_slug: str) -> ProjectBranch | None:
project = await Project.get_by_slug(self.tenant, project_slug)
if project:
return ProjectBranch(project)
A request to /acme/website/articles/ walks three levels — resolve the tenant, resolve the project within it, match the route within that. Each level carries its own context, and each level is its own self-contained scope.
The package is on PyPI:
pip install fastapi-resolve
Wiring it up is one call. Router.use adds a catch-all route to your FastAPI app, so register any direct FastAPI routes — health checks, OpenAPI, static files — before calling it.
from fastapi import FastAPI
from fastapi_resolve import Router
app = FastAPI()
router = Router()
# ... register routes on router ...
Router.use(app, router)
You can pass multiple routers to Router.use and they're tried in order, so you can keep top-level concerns separate:
Router.use(app, tenant_router, api_router, fallback_router)
The package also includes Negotiated, a return type for HTTP content negotiation — serving HTML, JSON or other content types from the same handler based on the Accept header. But that's a topic for another post.