A secure, server-driven app router for Flask and Jinja.
Project description
app-router
A secure, server-driven app router for Flask and Jinja with route-based templates, nested layouts, partial navigation, route-local assets, API routes, CSRF protection, and server-rendered fallbacks.
Overview
app-router is a Flask extension that adds a Next.js-style project shape to
normal Flask and Jinja applications. Flask remains responsible for routing,
auth, request handling, sessions, and responses. Jinja remains responsible for
templates and reusable UI macros. The bundled JavaScript only enhances
same-origin link navigation; direct requests and JavaScript-disabled browsers
still receive normal server-rendered HTML.
The Python decorators are the source of truth. Template folders organize views, but files do not create routes by themselves.
Route declared + matching page.html exists -> render page
Route declared + page.html missing -> 404
page.html exists + no declared route -> unreachable
Features
@router.page(...)for server-rendered page routes.@router.api(...)for JSON/API routes.- Next-style template mapping:
/blog/<slug>maps toblog/[slug]/page.html. - Automatic root and nested
layout.htmlwrapping. - Internal DOM boundaries for partial navigation.
- Built-in client runtime served from
/_app/router.js. - Local asset rewriting for
./file.extand@/path/file.ext. - Manifest-backed asset serving through
/_app/assets. - CSRF protection for unsafe page and API methods.
- Same-origin redirect validation.
- Built-in fallback 404 and 500 templates.
- Jinja globals:
csrf_token,csrf_input,cn,html_attrs, andapp_router. - Default security headers with configurable CSP.
Requirements
- Python 3.10+
- Flask
- Jinja2
- Werkzeug
- MarkupSafe
- itsdangerous
This repository includes pyproject.toml package metadata and the app-router
console script entry point. It does not currently include a dependency lock file
or tests.
Quick Start
from flask import Flask
from app_router import AppRouter
app = Flask(__name__)
app.config["SECRET_KEY"] = "change-me"
router = AppRouter(app)
@router.page("/")
def home():
return {
"message": "Hello",
"_meta": {"title": "Home"},
"_cache": True,
"_ttl": 60,
}
@router.api("/api/users")
def users():
return {"users": []}
The matching template for / is:
<!-- templates/page.html -->
<h1>{{ message }}</h1>
Run the Flask app normally. The first request renders full HTML. Same-origin links are enhanced by the packaged browser runtime when JavaScript is available.
Project Shape
A typical consuming Flask app can organize templates like this:
templates/
layout.html
page.html
about/
page.html
admin/
layout.html
settings/
page.html
dashboard/
page.html
_components/
sidebar.html
components/
ui/
button.html
card.html
static/
app.css
Meaning:
layout.html: shared layout wrapper.page.html: route page template.[slug]: dynamic route segment folder.components/ui/: conventional location for reusable Jinja macro files._components/: conventional location for route-local Jinja macro files.static/: normal Flask public static assets.
The router itself only gives special meaning to layout.html, page.html, and
dynamic folders such as [slug]. Component folders are normal Jinja template
organization and do not create routes.
Pages
Page routes register Flask routes and render matching Jinja page templates.
@router.page("/admin/settings", methods=["GET", "POST"])
def settings():
return {
"section": "settings",
"_meta": {
"title": "Settings",
"description": "Manage account settings",
},
}
The router handles the page lifecycle:
1. Confirm the matching page template exists.
2. Validate CSRF for unsafe methods when enabled.
3. Call the page loader.
4. Convert the loader result into template context or redirect response.
5. Render page.html.
6. Wrap it with available layout.html files.
7. Return full HTML or a partial JSON patch response.
Page loaders may return:
dict: template context.None: empty context.RedirectResult: fromrouter_redirect(...).flask.Response: direct custom response.
Special dictionary keys:
_meta:title,description, andimage._cache: enables public full-page cache headers when truthy._ttl: cache lifetime in seconds when_cacheis enabled._redirect: redirect URL._status: redirect status code, defaulting to303._message: redirect message included in partial redirect responses.
By default, page responses use Cache-Control: no-store. Enable _cache only
for public pages.
Route Mapping
Default route-to-template mapping:
/ -> templates/page.html
/about -> templates/about/page.html
/data/<slug> -> templates/data/[slug]/page.html
/user/<id> -> templates/user/[id]/page.html
/blog/<year>/<slug> -> templates/blog/[year]/[slug]/page.html
/admin/settings -> templates/admin/settings/page.html
You can override the template:
@router.page("/profile", template="account/profile.html")
def profile():
return {}
Catch-all Flask path converters are rejected for page routes. Mixed
static/dynamic route segments such as post-<id> are also rejected by the
mapper.
Blueprints
The router can bind to either a Flask app or a Blueprint.
from flask import Blueprint
from app_router import AppRouter
bp = Blueprint("site", __name__, template_folder="templates")
router = AppRouter(bp)
@router.page("/")
def index():
return {}
When bound to a Blueprint, the router initializes itself on the parent Flask app when the blueprint is registered.
Layouts
Layouts wrap pages automatically. Do not use Jinja {% extends %} for the
router layout chain.
<!-- templates/layout.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<nav>...</nav>
{{ children|safe }}
</body>
</html>
Nested layouts wrap descendants:
templates/layout.html
-> templates/admin/layout.html
-> templates/admin/settings/page.html
For /admin/settings, the router looks for:
templates/layout.html
templates/admin/layout.html
templates/admin/settings/page.html
The router injects internal boundaries around layout children:
<div data-router-boundary="root">
<div data-router-boundary="admin">
...
</div>
</div>
Developers should render children where child content belongs. The boundaries
are generated by the router and used by the client runtime.
Jinja Components
The package does not implement a separate component framework. Reusable components are standard Jinja macros organized by convention.
Global component convention:
templates/components/ui/button.html
templates/components/ui/card.html
Example:
{# templates/components/ui/button.html #}
{% macro button(variant="default") %}
<button class="{{ cn('button', 'button-' ~ variant) }}">
{{ caller() }}
</button>
{% endmacro %}
Usage:
{% import "components/ui/button.html" as ui %}
{% call ui.button(variant="primary") %}
Save
{% endcall %}
Route-local component convention:
templates/dashboard/_components/sidebar.html
Usage:
{% import "dashboard/_components/sidebar.html" as dashboard %}
{{ dashboard.sidebar() }}
These files are not routed or served by app-router; they are normal Jinja
templates imported by other templates.
APIs
API routes return JSON and do not render templates, layouts, or partial page patches.
@router.api("/api/users")
def users():
return {"users": []}
Tuple-style return values are supported:
return {"created": True}, 201, {"X-App": "app-router"}
Unsafe API methods are CSRF-protected by default when the route is registered
with POST, PUT, PATCH, or DELETE.
API responses use Cache-Control: no-store.
Client Navigation
The first page load is normal Flask SSR:
GET /admin/settings
The returned HTML includes router state metadata and the package-owned client script:
<script type="module" src="/_app/router.js" data-app-router-client></script>
When a user clicks a same-origin link, the client script sends:
GET /admin/settings
X-Flask-Router: partial
X-Flask-Current-Path: /admin
X-Flask-Current-Tree: root,admin
Accept: application/json
The server then:
1. Resolves the current route from X-Flask-Current-Path.
2. Recomputes the server-side current layout tree.
3. Compares it with X-Flask-Current-Tree.
4. Builds the target route layout tree.
5. Finds the deepest shared boundary.
6. Renders the needed page/layout fragment.
7. Returns a JSON patch response.
Supported partial response modes:
patch: replace a DOM boundary with server-rendered HTML.reload: perform normal full-page navigation.redirect: navigate to a redirect target.
If the response is not JSON, the state does not match, the boundary is missing,
or fetch fails, the client falls back to window.location.assign(...).
Partial Response Shape
A patch response looks like this:
{
"mode": "patch",
"url": "/admin/settings",
"boundary": "admin",
"html": "...rendered html...",
"tree": ["root", "admin"],
"meta": {
"title": "Settings",
"description": "Manage account settings",
"image": "/static/og/settings.png"
},
"cache": false,
"scripts": [],
"styles": []
}
Metadata from _meta is applied on full HTML responses and updated during
partial navigation:
titleupdates<title>ordocument.title.descriptionupdates the description meta tag.imageupdatesog:image.
Forms and CSRF
Forms are normal Flask forms. They should work without JavaScript.
Unsafe page and API methods are protected by default when registered with
POST, PUT, PATCH, or DELETE.
<form method="post">
{{ csrf_input() }}
<button type="submit">Save</button>
</form>
For JavaScript requests, send the token in either supported header:
X-CSRF-Token: ...
X-CSRFToken: ...
CSRF behavior:
- Tokens require Flask
SECRET_KEY. - Tokens are signed with
itsdangerous.URLSafeTimedSerializer. - Tokens are tied to a per-session seed.
- The default token max age is 8 hours.
- Override max age with
APP_ROUTER_CSRF_MAX_AGE.
Local Assets
Route templates and layouts can explicitly reference local assets:
<script type="module" src="./index.js"></script>
<link rel="stylesheet" href="./style.css">
<img src="./hero.webp" alt="Hero">
./file.ext resolves relative to the template file that contains the import.
Only same-directory simple filenames are allowed for ./ imports.
Reusable assets can use the @/ alias:
<script type="module" src="@/admin/settings/index.js"></script>
<link rel="stylesheet" href="@/components/ui/button.css">
@/path.ext resolves through the active Jinja loader using the path after
@/.
Only explicit imports are rewritten. A file existing next to page.html does
not load automatically.
Rewritten output uses opaque hashed URLs:
<script type="module" src="/_app/assets/a8f31c4d9e0f12345678.js"></script>
Allowed extensions:
.js, .css, .png, .jpg, .jpeg, .webp, .svg, .woff, .woff2
Security rules implemented by the asset resolver:
- No direct filesystem paths in browser asset URLs.
- No
../imports. - No backslashes in alias imports.
- Unsupported extensions are rejected.
- Missing files are rejected.
- Same-directory symlink escapes are rejected for
./imports. - Alias imports with symlink path parts are rejected.
- Unknown asset IDs return 404.
- Served assets include
X-Content-Type-Options: nosniff.
Route-local assets are public, immutable-cache assets by default because
private_assets defaults to False in @router.page(...).
Cache-Control: public, max-age=31536000, immutable
Use private_assets=True when route-local assets should not be cached:
@router.page("/admin", private_assets=True)
def admin():
return {}
Important: private_assets=True does not mean the asset route becomes
authentication-aware. In the current code it only changes asset cache headers to
Cache-Control: no-store. Protect private pages with normal Flask auth
decorators or checks on the page route, and do not put secrets in client
JavaScript or CSS.
Shared public files can still live in Flask's normal static/ folder. CDN
assets require a CSP change because the default CSP is same-origin.
Build Metadata
The CLI builds route and asset metadata without executing page loaders or pre-rendering HTML. In the normal case, run one command from your Flask project root:
app-router build
The command auto-detects a Flask app from FLASK_APP or common modules such as
app.py, wsgi.py, main.py, and application.py. It looks for app,
application, create_app(), or make_app().
Output:
.app-router/
manifest.json
routes.json
assets/
What the build does:
- Scans declared page and API routes.
- Checks whether each page template exists.
- Finds matching
layout.htmlfiles. - Scans matching page/layout template source for explicit asset imports.
- Resolves
./file.extand@/path/file.ext. - Copies hashed assets into
.app-router/assets/. - Writes
manifest.jsonfor runtime asset lookup. - Writes
routes.jsonfor route metadata.
What the build does not do:
- It does not execute page loaders.
- It does not pre-render HTML.
- It does not produce static pages.
- It does not decide static versus dynamic rendering; route metadata currently records dynamic rendering.
At runtime, the router automatically loads .app-router/manifest.json
when it exists. You can override the build directory:
router = AppRouter(app, build_dir="build/app-router")
or through Flask config:
app.config["APP_ROUTER_BUILD_DIR"] = "build/app-router"
Error Pages
The package installs Flask error handlers for 404 and 500 responses. It also
ships fallback 404.html and 500.html package templates.
Override them by creating app templates with the same names:
templates/
404.html
500.html
Error templates receive:
status_codemessageerrorrouterapp_router
If no template is found, the router renders a minimal HTML document shell.
Error responses use Cache-Control: no-store.
Built-In Jinja Helpers
The router installs these globals:
csrf_token(name="default"): return a signed token string.csrf_input(name="default"): render a hidden CSRF input.cn(...): merge CSS class names from strings, mappings, and iterables.html_attrs(...): render escaped HTML attributes.app_router: current router instance.
Example:
<form method="post">
{{ csrf_input() }}
<button
{{ html_attrs(
class_=cn("button", {"button-primary": primary}),
disabled=disabled
) }}
>
Save
</button>
</form>
Keyword names ending in _ are rendered without the trailing underscore, so
class_ becomes class.
Configuration
router = AppRouter(
app,
asset_url_path="/_app/assets",
client_url_path="/_app/router.js",
partial_header="X-Flask-Router",
security_headers=True,
csrf=True,
build_dir=".app-router",
)
Defaults:
asset_url_path:/_app/assetsclient_url_path:/_app/router.jspartial_header:X-Flask-Routersecurity_headers: enabledcsp: same-origin default CSPcsrf: enabledbuild_dir:.app-router
Default CSP:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
Flask config:
SECRET_KEY: required for CSRF token signing.APP_ROUTER_CSRF_MAX_AGE: CSRF max age in seconds.APP_ROUTER_BUILD_DIR: alternate runtime manifest directory.
Security Assessment
No critical vulnerability was identified in the reviewed source.
Implemented protections:
- Python decorators are the route source of truth; templates alone do not expose routes.
- Missing page templates return a 404.
- Page and API routes use the same loader path for full and partial requests, so normal Flask auth checks still apply.
- CSRF is enabled by default for unsafe page and API methods.
- Redirects are limited to local paths or same-origin absolute URLs.
- Partial navigation headers are treated as state hints. Mismatch causes reload.
- Asset serving is manifest-backed and only serves registered opaque asset IDs.
- Asset resolution rejects traversal, unsupported extensions, missing files, and symlink escapes covered by the resolver.
- API, partial, error, and uncached page responses use
Cache-Control: no-store. - Public assets use immutable cache headers and
nosniff. - Default security headers include CSP,
X-Content-Type-Options, andReferrer-Policy.
Known limitations and risks:
- The default CSP allows inline styles with
style-src 'unsafe-inline'. - Partial navigation uses
innerHTMLto insert trusted server-rendered HTML. Keep Jinja autoescape enabled and avoid marking untrusted input as safe. - Inline page scripts are not given a lifecycle by the client runtime; prefer explicit module files.
private_assets=Truedoes not enforce route authorization.- Build metadata stores source paths in
manifest.json; keep build artifacts out of public source disclosure channels if paths are sensitive. - This source tree does not include a dependency lock file or tests.
Recommended hardening:
- Add tests for route mapping, layout wrapping, CSRF, redirects, asset resolver security, manifest loading, error templates, and partial navigation.
- Use a stricter CSP if consuming templates do not need inline styles.
- Sanitize rich text before rendering it into templates.
- Protect private pages with normal Flask auth/RBAC and keep secrets out of frontend assets.
Current Limitations
- No automatic route creation from files.
- No catch-all page routes using Flask
pathconverters. - No static pre-rendering.
- No streaming rendering.
- No JavaScript component hydration.
- No bundled UI component library.
- No automatic frontend bundling or TypeScript pipeline.
- No prefetching.
- No page-specific JavaScript init/destroy lifecycle.
- No auth-aware private asset serving.
- No nested error boundaries.
Project Structure
app-router/
pyproject.toml Package metadata and app-router console script
app_router/
__init__.py Public API exports
router.py Core router, rendering, headers, errors, and internal routes
assets.py Asset resolver, HTML rewriter, manifest builder, and server
csrf.py CSRF token generation and validation
helpers.py Jinja and routing helpers
responses.py Redirect response helper
exceptions.py Package-specific exceptions
static/router.js Partial-navigation browser runtime
templates/ Built-in 404 and 500 templates
README.md Project documentation
Verification
This README was matched against the current source files in this directory.
Python source parsing with ast succeeds. A normal compileall check could not
complete in this environment because the filesystem is read-only and bytecode
writes to __pycache__ failed.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file app_router-0.1.0.tar.gz.
File metadata
- Download URL: app_router-0.1.0.tar.gz
- Upload date:
- Size: 34.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8a92ebe698b375247c4612f6e407cf0140f9ec59fc59c52e5bb36564301431fc
|
|
| MD5 |
7169a4bd2672b26e53ee40805a3d929d
|
|
| BLAKE2b-256 |
c361b9dd46df726dd5e0e61da9f7355f87b96de2e5a3a0e36f588005ae3d5d8a
|
File details
Details for the file app_router-0.1.0-py3-none-any.whl.
File metadata
- Download URL: app_router-0.1.0-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6654db06dec29e6b1a01beeb8ecff3836ee35ceb82f699498e0ebde67fbf7bcf
|
|
| MD5 |
9d9582821c0fdd136115fe4918300143
|
|
| BLAKE2b-256 |
a4ab6338293af248228057e6d5598223222f44a0cbb1740fe83a3eef3ae779fa
|