PythonMastery
intermediate 25 min read · lesson 4 of 12 in Web Frameworks

Flask Blueprints, Factories, and Production Layout

1 · The lesson

read

The single-file app.py from earlier lessons works beautifully — until it doesn't. Around the time you hit twenty routes, three forms, a half-dozen models, and need different configs for tests and production, the monolith starts to fight back. Imports tangle. The same app global is referenced from six places. You can't test with a different database without monkey-patching.

This lesson covers the grown-up Flask layout: the application factory pattern, blueprints for modular routing, config classes for environment switching, testing with pytest, and production deployment with gunicorn. It's the structure every Flask project of consequence eventually adopts.

Run locally with pip install flask flask-sqlalchemy flask-migrate gunicorn (waitress on Windows). Expected output is shown in comments. The production server (gunicorn) is Linux/macOS only — Windows uses waitress.


1. The Problem with One Big app.py

A single file Flask app has four growing pains:

  • Circular imports. Your models.py wants db from app.py. Your app.py wants User from models.py. Boom.
  • One global app. You can't easily create a second one for tests with a different config — the import has side effects.
  • No separation of concerns. Auth routes, billing routes, and admin routes all share one file. Twenty contributors editing the same file = merge hell.
  • Config is hard-coded. "Use Postgres in production and SQLite in tests" turns into spaghetti without a structure.

The fix is two patterns working together: blueprints to split routes into modules, and the application factory to build the app on demand.


python
myapp/
├── __init__.py             # contains create_app() — the factory
├── config.py               # Config, DevelopmentConfig, ProductionConfig, TestingConfig
├── extensions.py           # db = SQLAlchemy(), migrate = Migrate(), etc.
├── models/
│   ├── __init__.py
│   ├── user.py
│   └── post.py
├── blueprints/
│   ├── __init__.py
│   ├── main/
│   │   ├── __init__.py     # bp = Blueprint("main", __name__)
│   │   ├── routes.py
│   │   └── templates/main/
│   ├── auth/
│   │   ├── __init__.py     # bp = Blueprint("auth", __name__, url_prefix="/auth")
│   │   ├── routes.py
│   │   ├── forms.py
│   │   └── templates/auth/
│   └── api/
│       ├── __init__.py
│       └── routes.py
├── templates/
│   └── base.html           # shared layout
├── static/
│   ├── style.css
│   └── app.js
├── tests/
│   ├── conftest.py
│   ├── test_main.py
│   └── test_auth.py
├── migrations/             # created by `flask db init`
├── wsgi.py                 # production entrypoint: `app = create_app()`
└── requirements.txt

Names aren't sacred — what matters is the shape:

  • __init__.py is the entry point. Nothing executes at import time except trivial assignments.
  • Extensions (db, migrate, login_manager, ...) are module-level singletons created uninitialised. init_app(app) runs inside the factory.
  • Each blueprint is a self-contained folder: routes, forms, templates for that feature.

3. The Application Factory

python
# myapp/__init__.py
from flask import Flask

from .extensions import db, migrate, csrf
from .config import Config


def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    # Initialise extensions against this specific app
    db.init_app(app)
    migrate.init_app(app, db)
    csrf.init_app(app)

    # Register blueprints
    from .blueprints.main import bp as main_bp
    from .blueprints.auth import bp as auth_bp
    from .blueprints.api import bp as api_bp

    app.register_blueprint(main_bp)
    app.register_blueprint(auth_bp)
    app.register_blueprint(api_bp, url_prefix="/api")

    # Error handlers (could also live in a blueprint)
    @app.errorhandler(404)
    def not_found(_e):
        from flask import render_template
        return render_template("errors/404.html"), 404

    return app

What changed:

  • create_app() is a function. Call it to get a fresh app. Tests can call it with a different config. Multi-tenant deployments can call it per tenant.
  • Imports are inside the function. from .blueprints.main import bp runs lazily, after db has been initialised. No circular-import errors.
  • config_class=Config — pick the config you want. Config is the default; tests pass TestingConfig.
python
# myapp/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)
migrate = Migrate()
csrf = CSRFProtect()

Extensions are created once at module level and initialised against an app per-app-creation. This is the pattern that makes the factory work.


4. Defining a Blueprint

A blueprint is a mini-Flask: a place to register routes, error handlers, template folders, and static folders that get attached to the real app later.

python
# myapp/blueprints/auth/__init__.py
from flask import Blueprint

bp = Blueprint(
    "auth",                              # name — used in url_for("auth.login")
    __name__,
    url_prefix="/auth",                  # all routes here mount under /auth
    template_folder="templates",         # blueprint-local templates
)

from . import routes                     # noqa: E402 — register routes by importing
python
# myapp/blueprints/auth/routes.py
from flask import render_template, redirect, url_for, flash
from . import bp
from .forms import LoginForm


@bp.get("/login")
@bp.post("/login")
def login():
    form = LoginForm()
    if form.validate_on_submit():
        # ... authenticate ...
        flash("Logged in.", "success")
        return redirect(url_for("main.home"))      # note: dotted blueprint name
    return render_template("auth/login.html", form=form)


@bp.get("/logout")
def logout():
    # ... log out ...
    return redirect(url_for("main.home"))

Three things to note:

  • @bp.route (or @bp.get, @bp.post) instead of @app.route. Same semantics.
  • The route /login becomes /auth/login because of the blueprint's url_prefix.
  • url_for("auth.login") — the dotted form is the blueprint name plus the view name. Get this wrong and Flask raises BuildError.

5. Configuration Management

python
# myapp/config.py
import os
from pathlib import Path

basedir = Path(__file__).resolve().parent.parent


class Config:
    """Default config — shared baseline."""
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-do-not-use-in-prod")
    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{basedir / 'app.db'}")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    MAX_CONTENT_LENGTH = 5 * 1024 * 1024


class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_ECHO = True                          # log SQL


class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"  # fresh DB per test session
    WTF_CSRF_ENABLED = False                        # let tests POST without CSRF tokens


class ProductionConfig(Config):
    DEBUG = False
    # SECRET_KEY must come from env; if missing, fail loudly
    SECRET_KEY = os.environ["SECRET_KEY"]


config_map = {
    "development": DevelopmentConfig,
    "testing": TestingConfig,
    "production": ProductionConfig,
}
+ setup added so this can run · defines
import os  # noqa: F401
os.environ.setdefault("SECRET_KEY", "example-secret-key")
os.environ.setdefault("DATABASE_URL", "postgresql://user:password@localhost:5432/example")

The factory picks one:

python
def create_app(config_name=None):
    config_name = config_name or os.environ.get("FLASK_ENV", "development")
    config_class = config_map[config_name]
    app = Flask(__name__)
    app.config.from_object(config_class)
    ...
+ setup added so this can run · defines config_map, Flask, os
import os  # noqa: F401
os.environ.setdefault("FLASK_ENV", "example-flask-env")

# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

config_map = _AutoMock('config_map')
def Flask(*_a, **_kw):
    print('-> Flask() called')
    return _AutoMock('Flask()')
os = _AutoMock('os')

Three rules I've learned the hard way:

  • Production fails loudly on missing secrets. os.environ["SECRET_KEY"] raises if unset — that's what you want. Don't fall back to a dev default in prod.
  • Tests get their own config. In-memory SQLite, CSRF off, debug off (debug-on tests can mask bugs).
  • Never commit .env files. Secrets live in env vars, set by your deployment platform. See devops-secrets.

6. The g and current_app Globals

Inside a request, current_app is the running Flask instance (the one the factory built), and g is a per-request scratchpad:

python
from flask import current_app, g, request

@bp.before_request
def load_user():
    token = request.headers.get("Authorization")
    g.user = lookup_user(token) if token else None

@bp.get("/profile")
def profile():
    if g.user is None:
        return "unauth", 401
    return render_template("profile.html", user=g.user, app_name=current_app.config["APP_NAME"])
+ setup added so this can run · defines bp, render_template, lookup_user
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

bp = _AutoMock('bp')
def render_template(*_a, **_kw):
    print('-> render_template() called')
    return _AutoMock('render_template()')
def lookup_user(*_a, **_kw):
    print('-> lookup_user() called')
    return _AutoMock('lookup_user()')
  • g lives for one request. Use it to memoise per-request state — the current user, a DB session, a feature-flag snapshot.
  • current_app lets blueprints access app config without importing the app directly (which would re-create the circular-import problem the factory was meant to solve).

Outside a request — in scripts, in tests — push a context manually:

python
from myapp import create_app

app = create_app()
with app.app_context():
    from myapp.extensions import db
    db.create_all()                                 # works because we're in an app context

7. Cross-Blueprint URL Building

The dotted form again — url_for("blueprint.view"):

python
# In a route or template inside the auth blueprint:
return redirect(url_for("main.home"))               # cross-blueprint
return redirect(url_for(".login"))                  # same-blueprint shortcut

# In a template:
<a href="{{ url_for('api.users') }}">API</a>

A leading . means "this blueprint". url_for(".login") inside auth resolves to auth.login. Useful when you rename the blueprint.


8. Testing with pytest

app.test_client() is the in-process HTTP client. No real server needed; tests run in microseconds:

python
# tests/conftest.py
import pytest
from myapp import create_app
from myapp.extensions import db


@pytest.fixture
def app():
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        yield app
        db.session.remove()
        db.drop_all()


@pytest.fixture
def client(app):
    return app.test_client()
python
# tests/test_main.py
def test_home_loads(client):
    r = client.get("/")
    assert r.status_code == 200
    assert b"Items" in r.data


def test_create_item_redirects(client):
    r = client.post("/items/new", data={"name": "test"}, follow_redirects=False)
    assert r.status_code == 302
    assert r.headers["Location"].endswith("/")


def test_create_item_persists(client, app):
    client.post("/items/new", data={"name": "first"})
    with app.app_context():
        from myapp.models import Item
        from sqlalchemy import select
        items = db.session.execute(select(Item)).scalars().all()
        assert len(items) == 1
        assert items[0].name == "first"
+ setup added so this can run · defines db
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

db = _AutoMock('db')

A few patterns:

  • Fresh app per test. The app fixture creates a new app each test (not session-scoped) so test isolation is automatic.
  • CSRF off in TestingConfig. Tests post forms directly without rendering them first.
  • with app.app_context(): in assertions that touch the DB outside the client call.

Run with pytest. The whole suite for a small app finishes in under a second.


9. Production WSGI Servers

The development server (app.run() / flask run) is single-threaded, slow, and unsafe in production. Real servers:

Gunicorn — the standard on Linux/macOS:

bash
pip install gunicorn

# wsgi.py
from myapp import create_app
app = create_app("production")

# Start it
gunicorn --workers 4 --bind 0.0.0.0:8000 wsgi:app

# Or directly call the factory
gunicorn --workers 4 --bind 0.0.0.0:8000 'myapp:create_app()'

Waitress — pure Python, Windows-friendly, no fork dependency:

bash
pip install waitress
waitress-serve --listen=0.0.0.0:8000 wsgi:app

Worker count rule of thumb: (2 × CPU cores) + 1. Each worker is a separate process; they share nothing in memory. CPU-bound work scales linearly with workers; I/O-bound work benefits more from async workers (gunicorn -k gevent or switching to FastAPI).


10. nginx in Front of Gunicorn

In production you put nginx (or another reverse proxy) in front of Gunicorn. nginx handles:

  • TLS termination — HTTPS connections terminate at nginx; gunicorn talks plain HTTP on localhost.
  • Static files — static/ and uploaded media served directly by nginx, never touching Python.
  • Connection buffering — slow clients on slow networks don't tie up Python workers.
  • Compression, request limits, IP allowlists, request logging.

The shape:

python
Client → nginx :443 (TLS, static) → gunicorn :8000 (Python) → Postgres :5432

A minimal nginx server block:

nginx
server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location /static/ {
        alias /srv/myapp/static/;
        expires 30d;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Or skip the nginx setup entirely and put the app behind a managed platform — Fly.io, Railway, Render, AWS App Runner. They terminate TLS, run your container, and serve static assets without you writing nginx config. See devops-docker and devops-environments.


11. Common Mistakes

1. Circular imports

Symptom: ImportError: cannot import name 'db' from partially initialized module 'myapp.extensions'. Cause: models.py imports db at module top, and something db depends on imports models.py. Fix: keep extensions in extensions.py, import blueprints inside create_app(), and import models inside view functions if necessary.

2. Calling db.init_app(app) in a global

python
# WRONG — runs at import time, before there's a real app config
db.init_app(some_global_app)
+ setup added so this can run · defines some_global_app, db
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

some_global_app = _AutoMock('some_global_app')
db = _AutoMock('db')

Always call init_app() inside create_app(), where the right app and config exist. The whole point of the factory is to delay binding.

3. Inconsistent url_prefix

python
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
# and also
app.register_blueprint(auth_bp, url_prefix="/auth")     # registered twice → /auth/auth/...
+ setup added so this can run · defines Blueprint, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def Blueprint(*_a, **_kw):
    print('-> Blueprint() called')
    return _AutoMock('Blueprint()')
app = _AutoMock('app')

Pick one place to set the prefix — usually on the blueprint, not on the registration. Mixing the two doubles up the prefix.

4. Forgetting app.app_context() in scripts

python
# A standalone script that touches the DB
from myapp import create_app
from myapp.extensions import db

app = create_app()
db.create_all()                                     # RuntimeError: no application context

Wrap any DB call in with app.app_context():. Inside a request, Flask pushes the context for you; outside, you do it.

5. Hardcoded blueprint names in url_for

python
url_for("login")                                    # WRONG — needs the blueprint prefix
url_for("auth.login")                               # RIGHT
url_for(".login")                                   # also right if you're inside auth
+ setup added so this can run · defines url_for
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def url_for(*_a, **_kw):
    print('-> url_for() called')
    return _AutoMock('url_for()')

The hardcoded form silently raises BuildError once the route lives in a blueprint. Pay attention to the dot.

6. Tests that share state

If your app fixture is session-scoped and tests create rows, test 5 can see test 3's data. Use function scope (default) for the app and db fixtures, or wrap each test in a transaction that gets rolled back. The latter is faster on big test suites — search "pytest savepoint" for the pattern.

7. Running gunicorn with debug or app.run() in production

Both serve traffic, but both are wrong for prod. app.run(debug=True) exposes the Werkzeug debugger (RCE-as-a-feature). app.run(debug=False) is single-threaded and slow. Use gunicorn (or waitress on Windows) for any deployment a real user touches. See security-checklist.


🎯 Your Turn — Refactor to Factory + Blueprints

Take this single-file app:

python
# old_app.py — what you start from
from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)
app.config["SECRET_KEY"] = "dev"

users = {}
next_uid = 1


@app.get("/")
def home():
    return render_template("home.html", count=len(users))


@app.get("/users/")
def list_users():
    return render_template("users_list.html", users=users.values())


@app.post("/users/")
def create_user():
    global next_uid
    name = request.form.get("name", "").strip()
    if name:
        users[next_uid] = {"id": next_uid, "name": name}
        next_uid += 1
    return redirect(url_for("list_users"))


if __name__ == "__main__":
    app.run(debug=True)

Refactor into:

python
myapp/
├── __init__.py             # create_app() factory
├── config.py               # Config + DevelopmentConfig + TestingConfig
├── extensions.py           # (empty for now — no DB yet)
├── blueprints/
│   ├── __init__.py
│   ├── main/
│   │   ├── __init__.py     # bp = Blueprint("main", __name__)
│   │   └── routes.py
│   └── users/
│       ├── __init__.py     # bp = Blueprint("users", __name__, url_prefix="/users")
│       └── routes.py
└── templates/
    ├── base.html
    ├── home.html
    └── users_list.html

Requirements:

  • create_app() accepts a config_class (or name).
  • main blueprint owns /.
  • users blueprint owns /users/ with GET and POST. The blueprint sets url_prefix="/users" so the routes inside it are "" (which becomes /users/).
  • All url_for calls in templates use the dotted form.
  • A wsgi.py at the project root that creates the app for gunicorn.
Hint 1 — Blueprint with empty route A blueprint with url_prefix="/users" wants routes like @bp.route("/") (which becomes /users/). Don't write @bp.route("/users/") — that would become /users/users/. The route inside the blueprint is relative to the prefix.
Hint 2 — Templates and url_for in the new layout Templates can live in a top-level templates/ folder (shared) — Flask finds them. In templates, references to list_users become url_for("users.list_users"). References to home become url_for("main.home"). Forget the prefix and you'll get BuildError on the first request.
Show full solution
python
# myapp/__init__.py
from flask import Flask
from .config import Config


def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    from .blueprints.main import bp as main_bp
    from .blueprints.users import bp as users_bp
    app.register_blueprint(main_bp)
    app.register_blueprint(users_bp)

    return app
python
# myapp/config.py
import os


class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only")


class DevelopmentConfig(Config):
    DEBUG = True


class TestingConfig(Config):
    TESTING = True
    WTF_CSRF_ENABLED = False


class ProductionConfig(Config):
    DEBUG = False
    SECRET_KEY = os.environ["SECRET_KEY"]
+ setup added so this can run · defines
import os  # noqa: F401
os.environ.setdefault("SECRET_KEY", "example-secret-key")
python
# myapp/extensions.py
# Empty for now — no SQLAlchemy in this exercise. Real apps populate this.
python
# myapp/blueprints/main/__init__.py
from flask import Blueprint

bp = Blueprint("main", __name__)

from . import routes      # noqa: E402
python
# myapp/blueprints/main/routes.py
from flask import render_template
from . import bp
from ..users.store import users      # tiny shared store — see below


@bp.get("/")
def home():
    return render_template("home.html", count=len(users))
python
# myapp/blueprints/users/__init__.py
from flask import Blueprint

bp = Blueprint("users", __name__, url_prefix="/users")

from . import routes      # noqa: E402
python
# myapp/blueprints/users/store.py
# In a real app this would be replaced by a SQLAlchemy model.
users: dict[int, dict] = {}
_next_uid = [1]

def add_user(name: str) -> dict:
    uid = _next_uid[0]
    _next_uid[0] += 1
    users[uid] = {"id": uid, "name": name}
    return users[uid]
python
# myapp/blueprints/users/routes.py
from flask import render_template, request, redirect, url_for
from . import bp
from .store import users, add_user


@bp.get("/")
def list_users():
    return render_template("users_list.html", users=users.values())


@bp.post("/")
def create_user():
    name = (request.form.get("name") or "").strip()
    if name:
        add_user(name)
    return redirect(url_for("users.list_users"))
html
<!-- myapp/templates/base.html -->
<!doctype html>
<html>
<head><title>{% block title %}MyApp{% endblock %}</title></head>
<body>
    <nav>
        <a href="{{ url_for('main.home') }}">Home</a> |
        <a href="{{ url_for('users.list_users') }}">Users</a>
    </nav>
    <hr>
    {% block content %}{% endblock %}
</body>
</html>
html
<!-- myapp/templates/home.html -->
{% extends "base.html" %}
{% block content %}
    <h1>Home</h1>
    <p>{{ count }} users registered.</p>
{% endblock %}
html
<!-- myapp/templates/users_list.html -->
{% extends "base.html" %}
{% block content %}
    <h1>Users</h1>
    {% if users %}
        <ul>{% for u in users %}<li>{{ u.name }}</li>{% endfor %}</ul>
    {% else %}
        <p>No users yet.</p>
    {% endif %}
    <form method="post" action="{{ url_for('users.create_user') }}">
        <input name="name" placeholder="Name" required>
        <button>Add</button>
    </form>
{% endblock %}
python
# wsgi.py — production entry point
from myapp import create_app
from myapp.config import ProductionConfig

app = create_app(ProductionConfig)

Run development:

bash
export FLASK_APP=myapp        # PowerShell: $env:FLASK_APP="myapp"
flask run

Run production:

bash
gunicorn --workers 4 --bind 0.0.0.0:8000 wsgi:app
# Windows: waitress-serve --listen=0.0.0.0:8000 wsgi:app

What this gets right:

  • Factory pattern — create_app(config_class). Tests pass TestingConfig, production passes ProductionConfig, dev uses the default.
  • Two blueprints, two namespaces. main.home and users.list_users can never collide. New features add a new blueprint folder, never edit a shared file.
  • Templates use the dotted form. Rename the blueprint and you fix url_for calls in one place.
  • Production secret comes from env. ProductionConfig raises immediately on missing SECRET_KEY — better than running with a weak default.
  • wsgi.py for gunicorn. Production servers point to a stable, importable WSGI app object.

What's missing for a real app:

  • A real database — see flask-database for SQLAlchemy + Flask-Migrate. users as a dict is a stand-in.
  • Auth — auth-passwords, auth-jwt.
  • Tests — app.test_client() with a client fixture, as shown in Section 8.
  • Dockerfile + CI — devops-docker, devops-github-actions.
  • Logging, metrics, error tracking. The factory is where you wire those extensions up.

What You Learned

  • Single-file Flask apps don't scale past a handful of routes. Move to the factory + blueprint layout early — refactoring later is more painful than starting that way.
  • Blueprints are mini-Flask objects with their own routes, templates, and url_prefix. Each owns one feature area (auth, billing, api, admin).
  • The application factory — create_app(config_class) — builds a fresh app each call. Tests use a different config; production loads secrets from env vars.
  • Extensions (db, migrate, login_manager) live in extensions.py as module-level uninitialised objects. init_app(app) happens inside the factory.
  • url_for("blueprint.view") with the dotted name. Inside a blueprint, url_for(".view") is a same-blueprint shortcut.
  • g holds per-request state; current_app is the running app — both avoid circular imports.
  • Config classes (Config, DevelopmentConfig, TestingConfig, ProductionConfig) keep environment differences in one file. Production fails loudly on missing secrets.
  • app.test_client() with pytest gives sub-millisecond integration tests. CSRF off in TestingConfig. Fresh app per test.
  • Production: gunicorn (workers = 2 × cores + 1) or waitress on Windows. nginx in front for TLS, static files, and connection buffering — or a managed platform that bundles those.
  • Never app.run() in production. Single-threaded, no security hardening, debug=True is RCE.

You've now got the full Flask production stack. From here: