Website Generators & Routing
Static files in www/ cover fixed pages. When you want one web page per record of a DocType (one page per blog post, per product, per help article), use a Website Generator. The DocType drives the pages, and each published record gets its own URL.
Has Web View
A DocType becomes a generator when you enable "Has Web View" in the DocType form. Saving with "Has Web View" on does two things:
- Frappe rewrites the controller file to subclass
WebsiteGeneratorinstead ofDocument(set_base_class_for_controller()infrappe/core/doctype/doctype/doctype.py). - Validation now requires a
routefield on the DocType. If you have not added one yourself, save fails withField "route" is mandatory for Web Views(validate_website()in the same file).
# your_app/your_app/doctype/blog_post/blog_post.py
from frappe.website.website_generator import WebsiteGenerator
class BlogPost(WebsiteGenerator):
def get_context(self, context):
context.parents = [{"title": "Blog", "route": "/blog"}]
# add anything else the template needs
Besides the route field you add, "Has Web View" turns on two more DocType-level settings (not fields on the record):
- "Allow Guest to View" so visitors who are not logged in can open the page.
- "Is Published Field", where you pick which field on the DocType marks a record as published.
The web template lives in a templates subfolder next to the controller, named after the DocType: doctype/blog_post/templates/blog_post.html (Meta.get_web_template() in frappe/model/meta.py).
Route field
Each published record needs a route. If you leave it blank, WebsiteGenerator.set_route() fills it from the title when the record is published. See make_route() in frappe/website/website_generator.py.
The default route is the scrubbed title. If the DocType's route property is set in the DocType definition, the route becomes that_prefix/scrubbed-title. Scrubbing lowercases the text and replaces spaces and underscores with hyphens.
# title "My First Post" with DocType route prefix "blog"
# becomes route: blog/my-first-post
You can override route per record. Frappe strips leading and trailing slashes and dots and caps the length at 139 characters.
is_published
A generator page is served only when the record counts as published. WebsiteGenerator.is_website_published() checks the "is published" field you configured on the DocType (is_published_field). If a record has that field set to false, its route returns a 404.
You can override the logic. For example, the Web Form DocType uses its own condition_field through website properties. The default is: published if the condition field is truthy, otherwise always published when no condition field is set.
class BlogPost(WebsiteGenerator):
def is_website_published(self):
return bool(self.published) and self.published_on <= frappe.utils.today()
Request lifecycle
Every request hits application() in frappe/app.py, which splits traffic by path before the website router runs:
/api/...goes to the REST and RPC handler infrappe/api./backupsand/private/files/...return downloadable files./.well-known/...serves well-known files.- Everything else on GET, HEAD, or POST goes to
get_response(), which runs the website router.
Public files under /files are served by static middleware (NGINX in production), so they never reach the Python router.
How a route resolves to a page
PathResolver.resolve() in frappe/website/path_resolver.py returns the final endpoint and a renderer instance. It works in three stages:
- Redirect resolution.
resolve_redirect()checks thewebsite_redirectshook and the Route Redirects table in Website Settings. A match raisesfrappe.Redirectand the resolver returns aRedirectPage. - Route resolution. With no redirect,
resolve_path()maps the incoming path to an endpoint usingwebsite_route_rulesand the dynamic routes of DocTypes that have a web view. Awebsite_path_resolverhook can replace this step. - Renderer selection. The endpoint is passed to each renderer in order: static file, web form, document (generator), template page, print, list. The first one whose
can_render()returns true is used. For a generator, this is the document renderer, which matches the endpoint to a published record of a DocType that has a web view. If none match, the resolver returns aNotFoundPage.
Page renderers
A page renderer is a class that knows how to respond for a given endpoint. Each renderer has two methods:
can_render(): return true if this renderer can handle the path.render(): build and return the response.
The base class is BaseRenderer in frappe/website/page_renderers/base_renderer.py. It provides build_response() and leaves can_render and render for subclasses.
The standard renderers are tried in this order (see PathResolver.resolve()):
StaticPage: serves non-text files (anything that is not html, md, js, xml, css, txt, or py) from thewwwfolder of an app. Prefer the app'spublicfolder for static assets so NGINX serves them directly.WebFormPage: renders a Web Form when the path matches a Web Form route.DocumentPage: renders a generator document. It looks for a template in the DocType'stemplatesfolder named after the DocType, for exampledoctype/blog_post/templates/blog_post.html.TemplatePage: serves an HTML or markdown file from any app'swwwfolder. For a folder, it servesindex.htmlorindex.md.PrintPage: renders the print view of a document, using the standard print format unless the DocType sets adefault_print_format.ListPage: matches when the path is a DocType name that has a web view (or whose module definesget_list_context), and renders the standard portal list template.
Two more renderers handle errors: NotFoundPage responds with 404, and NotPermittedPage responds with 403.
Custom page renderer
For cases the standard renderers do not cover, register your own with the page_renderer hook. Custom renderers are checked before the standard ones.
# your_app/hooks.py
page_renderer = "your_app.renderers.CustomPage"
# your_app/renderers.py
from frappe.website.page_renderers.base_renderer import BaseRenderer
class CustomPage(BaseRenderer):
def can_render(self):
return self.path.startswith("custom/")
def render(self):
return self.build_response("<div>Custom Response</div>")
The class must define can_render and render. You can also subclass a standard renderer to reuse its behavior.
website_route_rules
website_route_rules is a hook for mapping a URL pattern to a target route. Use it for dynamic segments or to alias one path to another. Frappe evaluates these with Werkzeug routing, so you can capture parts of the path.
# your_app/hooks.py
website_route_rules = [
{"from_route": "/kb/<category>", "to_route": "Help Article"},
{"from_route": "/profile", "to_route": "me"},
]
The captured value (category above) lands in frappe.form_dict, so the target page or controller can read it. Rules are collected in get_website_rules() in path_resolver.py, which also adds a rule for every DocType that has a web view and a route set in its definition.
In development the rules are not cached, so changes show up immediately. In production they are cached and cleared when website cache is cleared.
Dynamic routes with www pages
You can serve dynamic URLs without a DocType by combining a www/ template, its
Python controller, and a website_route_rules entry. The rule maps a URL pattern
to a static template, and the captured segment is read in get_context.
To render a page per project at /project/<name>, add the rule:
# your_app/hooks.py
website_route_rules = [
{"from_route": "/project/<name>", "to_route": "project"},
]
Add the template and its controller next to each other in www/:
<!-- your_app/www/project.html -->
<h1>Project: {{ name }}</h1>
# your_app/www/project.py
import frappe
def get_context(context):
# the <name> segment from the URL is available in form_dict
context.name = frappe.form_dict.name
A request to /project/website-revamp matches the rule, runs project.py's
get_context, and renders project.html with frappe.form_dict.name set to
website-revamp.
Redirects
Two ways to redirect a route:
Hooks, for app-level redirects. Source can be a plain path or a regex, and the target can reference regex groups.
# your_app/hooks.py
website_redirects = [
{"source": "/app", "target": "/desk"},
{"source": r"/old/(.*)", "target": r"/new/\1", "forward_query_parameters": True},
]
Website Settings, for redirects an admin can edit without touching code. Add rows under "Route Redirects" in Website Settings (the route_redirects child table). These are merged with the hook redirects in resolve_redirect().
Redirects default to HTTP 301. Set redirect_http_status (in settings) to change it, and forward_query_parameters to carry the query string over to the target.