DEV Community

Navas Herbert
Navas Herbert

Posted on

I Built a SaaS to Prove a FastAPI Router Change Most People Missed

Every so often a framework change ships that looks small in the changelog and turns out to be a real architectural unlock. This is one of them.

FastAPI 0.140.0 quietly changed how include_router() works. The changelog says "preserve APIRouter/APIRoute instances instead of cloning them." That's one sentence. Here's what it actually means - and what you can build because of it.


The Old Behavior

When you called app.include_router(sub_router), FastAPI copied every route out of sub_router and rebuilt them inside the parent app. The sub_router object you kept a reference to was now stale.

Anything you added to it afterward was invisible to the running app - because the app was serving a snapshot from the moment of inclusion.

pro_router = APIRouter(prefix="/pro")
app.include_router(pro_router)

# This route is INVISIBLE - app already snapshotted the router at inclusion time
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str):
    ...
Enter fullscreen mode Exit fullscreen mode

The only way around this was to re-include the router - which meant either restarting the process or building a custom mount system most people wouldn't bother with.


The New Behavior

The original APIRouter and APIRoute instances are preserved instead of cloned.

The router object you included IS the router object serving traffic.

pro_router = APIRouter(prefix="/pro")
app.include_router(pro_router)

# This route IS visible - FastAPI holds a live reference to the same object
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str):
    ...
Enter fullscreen mode Exit fullscreen mode

Small sentence. Real consequence: a router can grow new endpoints after your app has already started - same process, no restart, no redeploy.


I Built Something to Prove It

Reading a changelog note is one thing. Watching it work in a live terminal is different. So I built SnipLink - a tiny link-shortener SaaS - specifically to demo this behavior.

The mechanic:

  • pro_router is defined once, with zero custom routes, and included into the app at startup
  • Free-tier users get a 404 on /pro/analytics/{slug} - the route literally does not exist yet
  • The moment a user calls /billing/upgrade, that handler registers a brand-new @router.get("/analytics/{slug}") directly on pro_router - the same live object FastAPI already holds
  • Same process. No restart. No redeploy.

Here's the full project structure:

sniplink/
├── app/
│   ├── main.py          # app setup, router inclusion
│   ├── routers/
│   │   ├── auth.py      # signup, api key generation
│   │   ├── links.py     # shorten, redirect
│   │   ├── billing.py   # upgrade - the moment the route appears
│   │   └── pro.py       # pro_router lives here, starts empty
│   └── core/
│       └── store.py     # in-memory storage
├── pyproject.toml
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

The Key Code

pro.py - the router that starts empty:

from fastapi import APIRouter, Depends
from app.core.store import store
from app.core.auth import get_current_user

pro_router = APIRouter(prefix="/pro", tags=["pro"])

def register_analytics_route():
    """
    Called at runtime - after the router is already included in the app.
    Under the old FastAPI behavior, this would have been invisible.
    Under 0.140.0+, the app holds a live reference to pro_router,
    so this route appears immediately, no restart needed.
    """
    @pro_router.get("/analytics/{slug}")
    async def analytics(slug: str, user=Depends(get_current_user)):
        link = store["links"].get(slug)
        if not link:
            return {"detail": "Unknown slug"}
        return {
            "slug": slug,
            "clicks": link["clicks"],
            "target_url": link["target_url"]
        }
Enter fullscreen mode Exit fullscreen mode

billing.py - the upgrade endpoint that fires the registration:

from fastapi import APIRouter, Depends
from app.routers.pro import register_analytics_route
from app.core.auth import get_current_user
from app.core.store import store

router = APIRouter(prefix="/billing", tags=["billing"])

@router.post("/upgrade")
async def upgrade(user=Depends(get_current_user)):
    # Upgrade the user's plan in storage
    store["users"][user["api_key"]]["plan"] = "pro"

    # Register the route on the live router object
    register_analytics_route()

    return {
        "plan": "pro",
        "message": "Pro unlocked. Try GET /pro/analytics/{slug} now."
    }
Enter fullscreen mode Exit fullscreen mode

main.py - inclusion happens once, at startup:

from fastapi import FastAPI
from app.routers.auth import router as auth_router
from app.routers.links import router as links_router
from app.routers.billing import router as billing_router
from app.routers.pro import pro_router

app = FastAPI(title="SnipLink")

app.include_router(auth_router)
app.include_router(links_router)
app.include_router(billing_router)
app.include_router(pro_router)   # starts empty - grows at runtime
Enter fullscreen mode Exit fullscreen mode

The Live Demo - Terminal Proof

Here's the full sequence, no edits:

Step 1 - Sign up (free tier):

curl -X POST localhost:8000/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "navas123@gmail.com"}'
Enter fullscreen mode Exit fullscreen mode
{"api_key": "sk_la3yzxfuetrazshtu92yhnib", "plan": "free"}
Enter fullscreen mode Exit fullscreen mode

Step 2 - Shorten a link:

curl -X POST localhost:8000/links \
  -H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib" \
  -H "Content-Type: application/json" \
  -d '{"target_url": "https://fastapi.tiangolo.com"}'
Enter fullscreen mode Exit fullscreen mode
{"slug": "pneu49", "target_url": "https://fastapi.tiangolo.com/", "clicks": 0}
Enter fullscreen mode Exit fullscreen mode

Step 3 - Hit the pro endpoint (free tier):

curl localhost:8000/pro/analytics/pneu49 \
  -H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
Enter fullscreen mode Exit fullscreen mode
{"detail": "Not Found"}
Enter fullscreen mode Exit fullscreen mode

The route doesn't exist. This isn't a permissions error - 404 Not Found means FastAPI has no matching route registered. The endpoint is not there.

Step 4 - Upgrade:

curl -X POST localhost:8000/billing/upgrade \
  -H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
Enter fullscreen mode Exit fullscreen mode
{"plan": "pro", "message": "Pro unlocked. Try GET /pro/analytics/{slug} now."}
Enter fullscreen mode Exit fullscreen mode

register_analytics_route() just fired. Same process. No restart.

Step 5 - Hit the same path again:

curl localhost:8000/pro/analytics/pneu49 \
  -H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
Enter fullscreen mode Exit fullscreen mode
{"slug": "pneu49", "clicks": 0, "target_url": "https://fastapi.tiangolo.com/"}
Enter fullscreen mode Exit fullscreen mode

The response changed. The route now exists.


The Detail Worth Slowing Down On

Look at Steps 3 and 5 again. Both requests hit the same URL. Same server. Same process. The difference is what happened between them.

Step 3 returns "Not Found" - FastAPI routing matched nothing.
Step 5 returns real data - the route is now registered and serving.

And open /docs between those two requests. The OpenAPI schema gains a new endpoint in real time - you can watch it appear in Swagger UI the moment the upgrade fires. No page reload trick. FastAPI regenerates the schema from the live router state.


What This Unlocks for SaaS

Before this refactor, dynamic route registration was fragile enough that most people avoided it. You'd reach for a different pattern - a middleware check, a feature flag in the handler, or just a full restart.

Now the pattern is clean: feature gating at the router level.

  • User on free tier → route doesn't exist → clean 404
  • User upgrades → route appears → feature is live
  • No middleware, no flag checks, no infrastructure change

This is different from a permission check inside a handler. The route genuinely doesn't exist for free users. They can't hit a 403, they can't probe the endpoint shape, they see exactly what unauthenticated users see for any nonexistent path.


One Caveat Worth Knowing

register_analytics_route() is called every time someone upgrades. If multiple users upgrade, the function runs multiple times. FastAPI handles duplicate route registration by keeping the first and ignoring subsequent identical paths - but it's worth being deliberate about this.

In production you'd want a guard:

_analytics_registered = False

def register_analytics_route():
    global _analytics_registered
    if _analytics_registered:
        return

    @pro_router.get("/analytics/{slug}")
    async def analytics(slug: str, user=Depends(get_current_user)):
        ...

    _analytics_registered = True
Enter fullscreen mode Exit fullscreen mode

Or use a module-level set to track which routes have been registered, keyed by path.


Deployment

One command. One minute.

fastapi deploy
Enter fullscreen mode Exit fullscreen mode
FastAPI Cloud
🚀 Deploying app...
✅ Build complete!
...
✅ Ready the chicken! 🐔
Your app is ready at https://sniplink.fastapicloud.dev
Enter fullscreen mode Exit fullscreen mode

One note worth saving if you use a nested app/ layout: run fastapi dev with no file path argument. Passing app/main.py explicitly hits a known import-path bug in nested layouts - it raises ModuleNotFoundError: No module named 'app'. Declare the entrypoint in pyproject.toml instead:

[tool.fastapi]
entrypoint = "app.main:app"
Enter fullscreen mode Exit fullscreen mode

That sidesteps the bug for both fastapi dev and fastapi deploy.


Try It Yourself

git clone https://github.com/Navashub/sniplink
cd sniplink
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
fastapi dev
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8000/docs and follow the upgrade sequence from /docs - watch the schema change in real time.

Live demo: https://sniplink.fastapicloud.dev

Requires fastapi[standard]>=0.140.0 - the first version with the router-preservation refactor this whole project exists to demo.


I'm a Data Engineer and AI Builder in Nairobi - building in public across FastAPI, RAG systems, data pipelines, and SaaS products. I write about what I'm building as I build it, mistakes included.

Top comments (0)