FastAPI Routers and Modular Apps

Learn to structure FastAPI apps with routers and modular files. This lesson shows how to break down large APIs into manageable, organized modules, making code cleaner, more maintainable, and scalable. A practical walkthrough guides you through creating routers and connecting them to your main app, with troubleshooting

Focus: FastAPI routers

Sponsored

You've built a few FastAPI endpoints, and they work beautifully. But as your API grows — adding users, products, orders, and analytics — your single main.py file starts to resemble a tangled ball of string. Every new route makes navigation harder, merge conflicts more painful, and testing more fragile. This lesson solves that pain by showing you how to structure your FastAPI applications with routers and modular files, transforming a sprawling monolith into a clean, maintainable codebase that scales with your project.

The problem this lesson solves

A typical beginner FastAPI project lives in one file. It starts simple:

from fastapi import FastAPI

app = FastAPI()

@app.get("/users")
def get_users():
    return [{"name": "Alice"}]

@app.get("/products")
def get_products():
    return [{"name": "Laptop"}]

At first, this is fine. But add authentication, database connections, background tasks, and a dozen more resources, and your main.py balloons to thousands of lines. You'll spend more time scrolling than coding. Debugging becomes a hunt through one massive file. And when your team tries to work simultaneously, every edit creates a merge conflict.

The core issue: a single-file app mixes routes, logic, and configuration, violating the single-responsibility principle and making it impossible to reuse or test modules independently. Without structure, your API is a liability, not an asset.

Core concept / mental model

Think of your FastAPI application as a building. The main.py file is the foundation and the lobby — it ties everything together and greets incoming requests. Each router is a dedicated floor or wing: the users department, the products department, and so on. Each floor has its own entrance (the prefix), its own staff (path operations), and its own internal layout (modules).

A router in FastAPI is a APIRouter instance that lets you group related path operations. You define them in separate files, then include them into the main app. This is like wiring the floors' electrical systems into the building's main grid.

Key terms to keep straight:

  • APIRouter: A mini FastAPI-like object that holds path operations (e.g., @router.get("/")).
  • Include: The app.include_router() method that merges the router's routes into the app.
  • Prefix: A URL path prefix added to all routes in a router (e.g., /api/v1/users) — but the router itself doesn't define it; you apply it when including.
  • Modular files: Separate .py files that each contain a router, keeping concerns isolated.

Pro tip: Treat main.py as the composition root: it should only assemble and configure components — not implement business logic. If you find yourself writing route handlers in main.py, you're missing the point.

How it works step by step

Here's the workflow you'll follow to modularize a FastAPI app:

  1. Create a router — Instantiate APIRouter() in a new file (e.g., app/users.py).
  2. Define path operations — Use the router decorators (@router.get(), @router.post()) exactly as you would with app.
  3. Include the router — In main.py, call app.include_router(users.router) and optionally add a prefix and tags.
  4. Organize by resource (or by domain) — Each router handles one logical entity or feature, making your codebase predictable.
  5. Add dependencies — You can attach dependencies to individual routers at creation time, so all routes in that file share them without repetition.
  6. Import with care — Use relative imports if your routers live inside a package (like from .users import router), avoiding circular imports.

The cause-and-effect is simple: routers let you separate concerns, and include_router composes them back into one app. The app's behavior is unchanged, but the code becomes human-friendly.

Hands-on walkthrough

Let's build a modular FastAPI app step by step. We'll assume this project structure:

project/
├── main.py
├── app/
│   ├── __init__.py
│   ├── users.py
│   └── products.py

1. Create a users routerapp/users.py:

from fastapi import APIRouter, HTTPException

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

USERS_DB = {"alice": {"name": "Alice", "age": 30}, "bob": {"name": "Bob", "age": 25}}

@router.get("/")
def list_users():
    return list(USERS_DB.values())

@router.get("/{username}")
def get_user(username: str):
    user = USERS_DB.get(username)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

2. Create a products routerapp/products.py:

from fastapi import APIRouter

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

PRODUCTS = [{"id": 1, "name": "Laptop", "price": 999}]

@router.get("/")
def list_products():
    return PRODUCTS

3. Wire everything togethermain.py:

from fastapi import FastAPI
from app.users import router as users_router
from app.products import router as products_router

app = FastAPI()

app.include_router(users_router)
app.include_router(products_router)

@app.get("/health")
def health_check():
    return {"status": "ok"}

Now run with uvicorn main:app --reload and visit http://127.0.0.1:8000/docs. You'll see two sections: users and products, each with its endpoints under the correct prefix. Expected output for curl http://127.0.0.1:8000/users:

[{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]

To add a version prefix at the app level (if you didn't set it in the router), change the include call:

app.include_router(users_router, prefix="/api/v1")
app.include_router(products_router, prefix="/api/v1")

Notice how the prefix defined inside the router combines with the include prefix, so user routes become /api/v1/users/. This flex to override or extend prefixes at inclusion time is a powerful feature.

Compare options / when to choose what

You have several ways to structure your FastAPI routes. Here's a comparison table to help you decide:

Approach When to use Pros Cons
Single file Quick prototypes, < 10 endpoints Simple, no extra files Hard to maintain, team conflicts
One router per resource Most APIs growing past a few endpoints Clear organization, easy to test and extend More files to manage
Feature-based routers (e.g., auth, admin) Route grouping by cross-cutting concern Logical separation by functionality May require nested routers for sub-resources
Nested routers (router within a router) Complex APIs with sub-resources like users/{id}/posts Reuse and nesting Adds indirection, may be overkill

For most projects, one router per resource is the sweet spot. If you have shared logic (e.g., an auth dependency), you can pass it as a router-level dependencies argument:

from fastapi import Depends, APIRouter

async def require_token(token: str = Header(...)):
    if token != "secret":
        raise HTTPException(401)
    return token

router = APIRouter(dependencies=[Depends(require_token)])

This way, every route in that router automatically enforces authentication.

Pro tip: Use tags liberally when creating routers — they not only organize your OpenAPI docs but also make test filtering easier with tools like httpx. Tags are free documentation.

Troubleshooting & edge cases

You'll likely run into a few common snags while modularizing. Here's how to fix them:

  • Route not found (404): The router isn't included, or the path / prefix is wrong. Check the include statement; if you set a prefix in the router, don't repeat it in the include prefix.
  • Circular imports: If main.py imports a router that imports back from main.py, you'll see an ImportError. Solution: keep routers independent; move shared state or helpers into separate modules.
  • Duplicate operation IDs: If you have multiple get("/") routes across routers with the same tag, FastAPI may complain about duplicate operation IDs in OpenAPI. Add operation_id parameters manually or ensure unique names.
  • Missing __init__.py: Without it, Python won't treat app as a package, causing import errors. Create empty app/__init__.py.
  • Path operation order: When you include multiple routers, FastAPI matches routes in the order they were included. If one router has a catch-all like /{item_id}, it might shadow later routes. Place more specific routes earlier.
  • Router prefix overridden unexpectedly: If you set prefix="/api" at include time, it adds to the router's own prefix. To replace, omit the router-level prefix and set it only at include.

What you learned & what's next

You now understand how to break down a monolithic FastAPI app into modular, maintainable routers. You've practiced creating APIRouter instances, defining endpoints across multiple files, and including them into a central app with optional prefixes and tags. You can systematically organize code, avoid circular imports, and keep your codebase clean as it scales — a skill that separates hobby scripts from production-grade APIs.

Next up, you'll likely dive into dependency injection in depth, learning how to share database sessions, authentication, and reusable logic across routers — building on the modular foundation you just created. Or, if you're moving forward in the track, explore background tasks and middlewares to add cross-cutting concerns. Either way, your structured app is ready to grow.

Practice recap

Take a small existing FastAPI app (even from memory) and split it into at least two routers. Add a prefix and tags to each, include them in main.py, and verify endpoints work via /docs. Then try overriding the prefix at include time to see how it changes the URL structure.

Common mistakes

  • Forgetting to include routers in main.py results in 404 errors — always call app.include_router().
  • Setting a prefix both in APIRouter and in include_router leads to unexpected double prefixes like /api/api/users.
  • Creating circular imports by having routers import from main.py — keep routers standalone and import shared helpers from separate modules.

Variations

  1. Using nested routers via APIRouter.include_router on another router to build sub-resources like /users/me/posts.
  2. Feature-based routers instead of resource-based — grouping by authentication, admin, or public endpoints.
  3. Organizing by domain with routers that combine related resources in one file for microservices.

Real-world use cases

  • A growing e-commerce API with separate routers for users, orders, and payments keeps each domain maintainable and independently testable.
  • A microservices gateway that exposes versioned endpoints by including routers with /v1 and /v2 prefixes
  • An admin dashboard API that shares authentication dependencies across all admin routers via router-level dependencies.

Key takeaways

  • Use APIRouter to group related endpoints in separate files, keeping main.py focused on configuration.
  • Include routers with app.include_router() and apply prefixes/tags at inclusion time for flexibility.
  • Maintain a clean package structure with app/init.py to avoid import errors.
  • Avoid circular imports by keeping routers independent and importing shared utilities from dedicated modules.
  • Use router-level dependencies to apply auth or validation across all routes in a group.
  • Organize routers by resource or feature to make your codebase scalable and team-friendly.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.