Skip to content

Web Development · Python

FastAPI's router overhaul in June 2026: what changed and why it matters

FastAPI merged a significant router internals refactor on June 14, 2026, followed by app.frontend() for serving SPAs a week later. Routes are now preserved instead of cloned, dynamic route registration works, and serving a React or Vue frontend from FastAPI no longer requires a workaround.

Abhishek Gupta

Abhishek Gupta

5 min read

FastAPI's router overhaul in June 2026: what changed and why it matters

Sponsored

Share

FastAPI’s router internals have worked the same way since the framework launched: when you include a sub-router into the app, FastAPI copies all the route objects out of that sub-router and recreates them on the parent. The result is a flat route list at the top level, with no structural memory of how you organized things.

PR #15745, merged June 14, 2026, changes that. Routes are now preserved as objects rather than cloned. The change is internal and the public API didn’t break, but the implications are meaningful if you’ve run into the limitations of the old model.

What the old behaviour was

When you wrote:

from fastapi import FastAPI, APIRouter

app = FastAPI()
users_router = APIRouter(prefix="/users")

@users_router.get("/{user_id}")
def get_user(user_id: int):
    return {"id": user_id}

app.include_router(users_router)

# This doesn't work in the old model:
@users_router.get("/me")
def get_current_user():
    return {"id": "me"}

The route GET /users/me defined after include_router() was silently ignored. Inclusion was a one-time copy operation. Whatever was in the router at the moment of inclusion was what you got — anything added after was invisible to the app.

This was a documented limitation, but it caused real friction in patterns like:

  • Factory functions that return a pre-configured router and later add routes
  • Plugin-style architectures where a sub-router is extended after initial setup
  • Testing setups that add test-only routes after application bootstrap

What changed

The refactor introduces intermediate metadata classes that store the record of “router X includes Y and Y includes Z.” Instead of flattening this into one route list, FastAPI keeps the inclusion structure alive.

The APIRouter and APIRoute objects themselves are preserved and used directly rather than having their data copied into new objects. This means:

app.include_router(users_router)

# This now works:
@users_router.get("/me")
def get_current_user():
    return {"id": "me"}

# GET /users/me is visible to the app because the router reference is live

You can also include a sub-router before adding routes to it:

main_router = APIRouter()
sub_router = APIRouter(prefix="/items")

main_router.include_router(sub_router)  # include first

@sub_router.get("/{item_id}")            # add route after — still reflected because the router reference is live
def get_item(item_id: int):
    return {"id": item_id}

app.include_router(main_router)

The routes added to sub_router after inclusion show up because the entire object is stored, not a snapshot. Memory usage also drops slightly in apps with many routes, since route objects aren’t duplicated.

New methods: .matches() and .handle()

APIRouter now has two methods that mirror the existing API on APIRoute:

  • .matches(scope): given an ASGI scope dict, returns whether this router would handle the request
  • .handle(scope, receive, send): handle the request directly from the router level

These open up routing behaviour that wasn’t possible before. Instead of routing purely based on URL prefix, you can write a router that makes its matching decision based on request headers, host, query parameters, or any other information in the ASGI scope.

A concrete use case: API versioning by header rather than URL prefix.

from fastapi import FastAPI, APIRouter, Request
from starlette.types import Scope, Receive, Send

class VersionRouter(APIRouter):
    def __init__(self, version: str, **kwargs):
        super().__init__(**kwargs)
        self.version = version

    def matches(self, scope: Scope):
        # Only claim this request if the API-Version header matches
        headers = dict(scope.get("headers", []))
        api_version = headers.get(b"api-version", b"").decode()
        if api_version == self.version:
            return super().matches(scope)
        return None  # decline — let the next router try

v1_router = VersionRouter(version="1", prefix="/api")
v2_router = VersionRouter(version="2", prefix="/api")

@v1_router.get("/users")
def get_users_v1():
    return {"version": 1, "users": []}

@v2_router.get("/users")
def get_users_v2():
    return {"version": 2, "users": [], "pagination": {}}

app = FastAPI()
app.include_router(v1_router)
app.include_router(v2_router)

This is an advanced pattern. Most applications route by URL prefix and don’t need .matches(). But for teams that have been working around the limitation with custom middleware or duplicate routes, it’s a clean solution.

app.frontend() for serving SPAs

One week after the router refactor, on June 21, FastAPI added app.frontend() — a single method call that handles the common pattern of co-hosting a single-page application with a FastAPI backend.

The previous approach required writing a StaticFiles mount plus a catch-all route:

from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os

# Old way
app.mount("/static", StaticFiles(directory="dist/static"), name="static")

@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
    index_path = os.path.join("dist", "index.html")
    return FileResponse(index_path)

The new way:

app.frontend("./dist")

Under the hood, this does the same thing: serves static files from the directory, then falls back to index.html for any path that doesn’t match a static file or an API route. The catch-all is registered after all other routes, so your API endpoints still take precedence.

You can customize the directory structure:

app.frontend(
    "./dist",
    static_path="/assets",    # serve static files at /assets instead of /
    index_file="index.html",  # the fallback file
)

This is useful when your build output separates assets into a subdirectory but you want the app’s routes to stay at root.

What this means in practice

Most FastAPI code doesn’t need to change. include_router(), prefix, tags, dependencies — all of that works exactly as before. The difference shows up in two situations:

If you have been working around the dynamic route limitation — factory functions, plugin registries, test fixtures that add routes after bootstrap — you can now remove the workaround. Routes added to an included router are live.

If you co-host a frontend — deploying a React, Vue, or Svelte app alongside your FastAPI backend in a single process — app.frontend() is cleaner than the three-file workaround. Particularly useful for development setups and small self-contained deployments that don’t need a separate static hosting layer.

For a comparison of FastAPI’s architectural tradeoffs against Django REST Framework, including how they handle routing at scale, the DRF vs FastAPI comparison is still current.

The June 2026 updates don’t change FastAPI’s positioning in the Python ecosystem — it’s still the framework to reach for when you need an async Python API with minimal boilerplate and automatic OpenAPI docs. They do remove two specific limitations that have been minor friction points for a while.

Frequently asked questions

Why did FastAPI clone routes before, and what was the problem?
When you called router.include_router(sub_router), FastAPI took every route from sub_router and recreated it from scratch on the parent router. The result was a flat list of routes at the top level. There was no record of the original sub-router structure. The problem: if you added a route to sub_router after it was already included, the parent router didn't know — the copy was already made.
What does it mean that routes are now 'preserved as objects'?
Instead of copying route data into the parent router at include time, FastAPI now stores a reference to the included router. The route hierarchy is remembered. Routes added later to a sub-router are visible to the parent because the parent holds a live reference, not a snapshot. It also means the original APIRouter and APIRoute objects are what the app actually uses — not reconstructed duplicates.
How does app.frontend() work in FastAPI?
app.frontend(path='./dist') tells FastAPI to serve static files from the specified directory and to fall back to index.html for any path that doesn't match a static file or API route. This is the standard SPA hosting pattern: the frontend's router (React Router, Vue Router) handles path-based navigation client-side, and the server just needs to return index.html for anything that isn't an API call or a known static asset.
Do I need to change any existing code after this update?
No. The public API is backward compatible. If you have existing routers using include_router(), they continue to work. The difference is visible in two scenarios: you were relying on dynamic route addition after include (which now works), or you want to use the new .matches() and .handle() methods on APIRouter for custom routing logic.
When should I use .matches() and .handle() on APIRouter?
These are for cases where you want a router to decide which requests it handles based on runtime logic beyond a URL prefix. Examples: routing based on request headers, implementing versioned API routing where the version comes from a header rather than the URL, or A/B routing between two implementations of the same endpoint. Most applications don't need this — it's for when you've hit the limits of prefix-based routing.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored