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 well while the app is small. But when you have about twenty routes, three forms and six models, and you need different configs for tests and production, one big file becomes hard to work with. The imports get tangled. The same app global is used in six places. You can't test with a different database without monkey-patching.

This lesson covers the layout that larger Flask projects use: the application factory pattern, blueprints for modular routing, config classes for environment switching, testing with pytest, and production deployment with gunicorn. Most serious Flask projects end up with this structure.

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


1. The Problem with One Big app.py

A single-file Flask app has four problems as it grows:

  • Circular imports. Your models.py wants db from app.py. Your app.py wants User from models.py. Each file imports the other, and Python raises an ImportError.
  • One global app. You can't easily create a second one for tests with a different config, because importing the module already creates the app.
  • No separation of concerns. Auth routes, billing routes, and admin routes all share one file. When twenty people edit the same file, you get many merge conflicts.
  • Config is hard-coded. Without a clear structure, "use Postgres in production and SQLite in tests" turns into messy, hard-to-follow code.

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 the factory function create_app()
├── config.py               # Config, DevelopmentConfig, ProductionConfig, TestingConfig
├── extensions.py           # db = SQLAlchemy(), migrate = Migrate(), and so on
├── 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

You can change the names. What matters is the structure:

  • __init__.py is the entry point. Nothing runs at import time except simple assignments.
  • Extensions (db, migrate, login_manager, ...) are module-level objects, created once and not yet initialised. 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, and 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. They are then initialised with each app that the factory creates. This is the pattern that makes the factory work.


4. Defining a Blueprint

A blueprint is like a small Flask app: 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",                              # the blueprint name, which url_for("auth.login") uses
    __name__,
    url_prefix="/auth",                  # every route in this blueprint starts with /auth
    template_folder="templates",         # templates that belong to this blueprint
)

from . import routes                     # noqa: E402 (importing routes registers them)
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. They work the same way.
  • 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. If you get this wrong, 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. The other configs build on it."""
    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.get("SECRET_KEY")        # create_app() refuses to start without it


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")

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)
    if config_name == "production" and not app.config["SECRET_KEY"]:
        raise RuntimeError("SECRET_KEY must be set in production")
    ...
+ setup added so this can run · defines config_map, Flask, os
# 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:

  • Production fails loudly on missing secrets. create_app("production") raises an error if SECRET_KEY is unset, and that is what you want. (A bare os.environ["SECRET_KEY"] in the class body would break dev and test runs too, because every config class runs at import.) 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 store per-request state, such as the current user, a DB session or a snapshot of feature flags.
  • 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 and tests), push a context yourself:

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"))               # a route in another blueprint
return redirect(url_for(".login"))                  # shortcut for a route in the same blueprint

# 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. This is useful when you rename the blueprint.


8. Testing with pytest

app.test_client() is the in-process HTTP client. You don't need a real server, so tests run fast:

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 for each test (it is not session-scoped), so tests can't affect each other.
  • 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 not built for speed or security, so don't use it in production. Use a production server instead:

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, written in pure Python. It works on Windows because it does not need fork:

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, and workers share no memory. CPU-bound work gets faster with more workers, up to the number of CPU cores. 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 end at nginx, and nginx talks plain HTTP to gunicorn on localhost.
  • Static files: nginx serves static/ and uploaded media directly, without Python.
  • Connection buffering: clients on slow networks don't keep Python workers busy.
  • 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 and use a managed platform such as Fly.io, Railway, Render or 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: this runs at import time, before a real app config exists
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 main purpose of the factory is to connect extensions to the app later, not at import time.

3. Inconsistent url_prefix

python
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
# and also
app.register_blueprint(auth_bp, url_prefix="/auth")     # this value replaces the blueprint's: still /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. Setting both doesn't double the prefix. The registration value replaces the blueprint's value without any warning, so changing the blueprint later does nothing.

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 a request, you do it yourself.

5. Hardcoded blueprint names in url_for

python
url_for("login")                                    # Wrong: the name 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 form without the blueprint name raises BuildError once the route is in a blueprint. Don't forget 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 second way is faster on big test suites. Search for "pytest savepoint" to find the pattern.

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

Both serve traffic, but neither is right for production. app.run(debug=True) exposes the Werkzeug debugger, which lets a visitor run Python code on your server. app.run(debug=False) is still the development server, which is not built for production traffic. 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 (the code 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, because there is 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: Routes inside a blueprint A blueprint with url_prefix="/users" wants routes like @bp.route("/") (which becomes /users/). Don't write @bp.route("/users/"), because 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), and Flask finds them. In templates, references to list_users become url_for("users.list_users"). References to home become url_for("main.home"). If you forget the prefix, 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)
    if config_class.__name__ == "ProductionConfig" and not app.config["SECRET_KEY"]:
        raise RuntimeError("SECRET_KEY must be set in production")

    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.get("SECRET_KEY")        # create_app() refuses to start without it
+ 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, because this exercise has no SQLAlchemy. Real apps fill this file.
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      # a tiny shared store (shown 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 (the 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. A new feature gets a new blueprint folder, so nobody has to edit a shared file.
  • Templates use the dotted form. Every route is clearly named by blueprint, and Flask raises BuildError if a name is wrong.
  • Production secret comes from env. ProductionConfig raises an error at once if SECRET_KEY is missing. This is 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:


What You Learned

  • Single-file Flask apps get hard to manage after a few routes. Move to the factory + blueprint layout early, because refactoring later is harder than starting that way.
  • Blueprints are like small Flask apps, 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 time you call it. Tests use a different config, and 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, and current_app is the running app. Both help you avoid circular imports.
  • Config classes (Config, DevelopmentConfig, TestingConfig, ProductionConfig) keep environment differences in one file. Production stops with an error if a secret is missing.
  • app.test_client() with pytest gives fast 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 does those things for you.
  • Never app.run() in production. The development server is not built for production traffic, and with debug=True a visitor can run code on your server.

You now know the full Flask production stack. From here:

Next: FastAPI Basics.

Found this useful? Share it with a friend who’s learning Python.