
Frappe Impl Website
- 56 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Build Frappe portal pages, Web Forms, blogs, and website routes with Jinja templates while avoiding 404s and missing SEO meta tags.
About
Guides building portal pages, Web Forms, website routes, blogs, and themes in Frappe/ERPNext. A developer uses it when adding public-facing or customer-portal pages to a Frappe app.
- Portal pages, Web Forms, Web Pages, and website routes in Frappe
- Covers Jinja templates, themes, meta tags, sitemap, and Blog
Frappe Impl Website by the numbers
- 56 all-time installs (skills.sh)
- Ranked #1,248 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-impl-websiteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 56 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Build Frappe portal pages, Web Forms, blogs, and website routes with Jinja templates while avoiding 404s and missing SEO meta tags.
Files
Frappe Website & Portals — Implementation Workflows
Step-by-step workflows for building websites, portals, and public-facing pages. For hooks syntax see frappe-impl-hooks. For Jinja templating see frappe-impl-jinja.
Version: v14/v15/v16 | Note: v15+ uses Bootstrap 5; v14 uses Bootstrap 4.
Quick Decision: Which Page Type?
WHAT do you need?
├── Static content page (About, Terms) → Web Page DocType or www/ HTML
├── Data entry by external users → Web Form
├── List of records visible on website → has_web_view on DocType
├── Blog / news articles → Blog Post + Blog Category
├── Custom app with sidebar/toolbar → Custom Portal Page (www/)
└── Dynamic route with parameters → website_route_rules in hooks.pySee references/decision-tree.md for the complete decision tree.
Workflow 1: Create a Portal Page (www/)
Portal pages live in your app's www/ directory. The file name becomes the URL route.
1. Create myapp/www/custom_page.html:
{% extends "templates/web.html" %}
{% block page_content %}
<h1>{{ title }}</h1>
<div>{{ content }}</div>
{% endblock %}2. Create matching controller myapp/www/custom_page.py:
import frappe
def get_context(context):
context.title = "My Custom Page"
context.content = "Hello World"
context.no_cache = 1 # ALWAYS set for dynamic content3. Result: page available at /custom_page
File types auto-loaded: .html (template), .py (controller), .css (styles), .js (scripts).
Subdirectory pattern — for nested routes:
myapp/www/
├── services/
│ ├── index.html → /services
│ ├── index.py
│ ├── consulting.html → /services/consulting
│ └── consulting.pyContext Variables Reference
| Key | Type | Effect |
|---|---|---|
title | str | Page title and browser tab |
no_cache | bool | Disable page caching |
no_header | bool | Hide the page header |
no_breadcrumbs | bool | Remove breadcrumbs |
add_breadcrumbs | bool | Auto-generate from folder structure |
show_sidebar | bool | Display web sidebar |
sitemap | int | 0 = exclude from sitemap, 1 = include |
metatags | dict | SEO meta tags (see Workflow 7) |
Rule: ALWAYS set no_cache = 1 for pages with user-specific or frequently changing content.
Workflow 2: Create a Web Form
Web Forms let external users submit data that creates Frappe documents.
1. Navigate to Web Form list → New Web Form 2. Set Title, select target DocType, set Route (URL slug) 3. Add fields — ALWAYS match fieldname to the target DocType field names 4. Configure access:
- Login Required: uncheck for guest submissions
- Allow Edit: let users edit their submissions
- Allow Multiple: let users submit more than once
5. Save and publish
Guest Submissions
ALLOWING guest submissions?
├── YES → Uncheck "Login Required"
│ → Set "Guest Title" for the submission form
│ → ALWAYS add rate limiting in site_config:
│ "rate_limit": {"web_form": "5/hour"}
│ → ALWAYS validate server-side (guests can bypass JS)
└── NO → Keep "Login Required" checked (default)Web Form Custom Script (Client)
frappe.web_form.on("after_load", function() {
// Runs after form loads in browser
});
frappe.web_form.on("before_submit", function() {
// Validate before submission — return false to cancel
let val = frappe.web_form.get_value("email");
if (!val) {
frappe.throw("Email is required");
return false;
}
});
frappe.web_form.on("after_submit", function() {
// Redirect or show message after success
window.location.href = "/thank-you";
});Web Form Custom Script (Server: Python)
In the Web Form document, add a Python script:
def get_context(context):
# Add custom context variables for the template
context.categories = frappe.get_all("Category", fields=["name", "title"])Rule: NEVER trust client-side validation alone for Web Forms. ALWAYS validate in the target DocType's controller or server script.
Workflow 3: Enable has_web_view on a DocType
This makes individual documents accessible as web pages (e.g., /articles/my-article).
1. Open DocType → check Has Web View and Allow Guest to View 2. Set the Route field prefix (e.g., articles) 3. ALWAYS add these fields to the DocType:
route(Data, hidden) — auto-generated URL slugpublished(Check) — controls visibility
4. Create templates in the DocType directory:
{doctype_name}.html— single record template{doctype_name}_row.html— list item template
5. In hooks.py, register as website generator:
website_generators = ["Article"]6. In the controller, implement get_context:
class Article(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/article.html",
condition_field="published",
page_title_field="title",
)
def get_context(self, context):
context.related = frappe.get_all(
"Article",
filters={"published": 1, "name": ("!=", self.name)},
fields=["title", "route"],
limit=5,
)Rule: ALWAYS include a published check field. NEVER expose unpublished documents to guests.
Workflow 4: Website Route Rules (hooks.py)
Route rules map URL patterns to controllers or pages.
# hooks.py
website_route_rules = [
# Map parameterized URL to a page
{"from_route": "/projects/<name>", "to_route": "projects/project"},
# Map URL prefix to DocType
{"from_route": "/kb/<path:name>", "to_route": "knowledge-base"},
]
# Redirects (301/304)
website_redirects = [
{"source": "/old-page", "target": "/new-page"},
{"source": r"/docs(/.*)?", "target": r"https://docs.example.com\1"},
]
# Homepage for logged-in users (role-based)
role_home_page = {
"Customer": "orders",
"Supplier": "rfqs",
}
# Dynamic homepage
get_website_user_home_page = "myapp.utils.get_home_page"Priority order for homepage: get_website_user_home_page > role_home_page > Portal Settings > Website Settings.
Workflow 5: Blog Setup
1. Create Blog Category documents (e.g., "News", "Updates") 2. Create Blog Post documents:
- Select category, write content (Markdown or Rich Text)
- Set Published and Published On date
- Blog route auto-generates as
/blog/{slug}
3. Configure in Website Settings:
- Set blog title
- Enable/disable comments
Rule: ALWAYS set Published On date — posts without a date NEVER appear in RSS feeds.
Workflow 6: Website Theme & Custom CSS
Via Website Theme DocType
1. Navigate to Website Theme → New 2. Configure: fonts, colors, navbar style, button radius 3. Add custom CSS in the Custom CSS field 4. Set as active theme in Website Settings
Via hooks.py
# Inject CSS/JS on all web pages
website_context = {
"favicon": "/assets/myapp/images/favicon.png",
}
update_website_context = "myapp.overrides.website_context"
# Override base template
base_template = "myapp/templates/custom_base.html"Workflow 7: SEO: Meta Tags, Open Graph & Sitemap
In portal pages (frontmatter or context)
def get_context(context):
context.metatags = {
"title": "My Page Title",
"description": "Page description for search engines",
"image": "/assets/myapp/images/og-image.png",
"og:type": "website",
"twitter:card": "summary_large_image",
}In Web Page DocType
Set meta fields directly: Meta Title, Meta Description, Meta Image.
Sitemap
- Frappe auto-generates
/sitemap.xmlfrom published Web Pages and has_web_view documents - Exclude pages: set
sitemap = 0in context or frontmatter - Custom robots.txt: set
robots_txtpath insite_config.json
Rule: ALWAYS set meta description on public pages. NEVER leave it empty — search engines penalize pages without descriptions.
Workflow 8: Guest Access & Security
# site_config.json — rate limiting
{
"rate_limit": {
"web_form": "5/hour",
"api": "100/hour"
},
"allowed_referrers": ["https://mysite.com"],
"allow_cors": "https://mysite.com"
}Security rules:
- ALWAYS enable CSRF protection (default). NEVER set
ignore_csrfin production - ALWAYS rate-limit guest-accessible endpoints
- ALWAYS sanitize user input in Web Forms (Frappe does this by default for standard fields)
- NEVER expose internal DocType names in guest-facing URLs without access control
Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
Hard-coding HTML in get_context | Use Jinja templates with context variables |
Skipping no_cache on dynamic pages | ALWAYS set no_cache = 1 for user-specific content |
| Guest Web Form without rate limiting | ALWAYS configure rate limits for guest forms |
Missing published field on has_web_view | ALWAYS add published check to prevent data leaks |
Using website_route_rules for simple redirects | Use website_redirects instead |
| Putting business logic in www/ controllers | Keep in DocType controllers; www/ is for presentation |
See references/anti-patterns.md for expanded anti-patterns with examples.
See Also
frappe-impl-hooks— Website hooks in detailfrappe-impl-jinja— Jinja templating patternsfrappe-impl-controllers— DocType controllers (WebsiteGenerator)frappe-syntax-clientscripts— Client-side API for Web Formsreferences/generators.md— Portal generators, blog system, custom routing patternsreferences/workflows.md— Extended workflow walkthroughsreferences/examples.md— Complete code examplesreferences/decision-tree.md— Full decision tree for page types
Website Anti-Patterns
AP-1: Hard-coding HTML in get_context
Wrong:
def get_context(context):
context.content = "<h1>Welcome</h1><p>Hello " + user + "</p>"Correct: Use Jinja templates for all HTML rendering. get_context provides data only.
def get_context(context):
context.user_name = frappe.get_value("User", frappe.session.user, "full_name")AP-2: Missing no_cache on Dynamic Pages
Wrong: Serving user-specific content without no_cache.
def get_context(context):
context.my_orders = get_user_orders() # User-specific but cached!Correct: ALWAYS set no_cache = 1 when content varies per user or request.
def get_context(context):
context.no_cache = 1
context.my_orders = get_user_orders()AP-3: Guest Web Form Without Rate Limiting
Wrong: Allowing guest submissions with no protection.
Correct: ALWAYS configure rate limits in site_config.json:
{ "rate_limit": { "web_form": "5/hour" } }AP-4: Missing Published Field on has_web_view
Wrong: Enabling Has Web View without a published field — ALL records become public.
Correct: ALWAYS add a published (Check) field and set condition_field="published" in the WebsiteGenerator config.
AP-5: Using website_route_rules for Simple Redirects
Wrong:
website_route_rules = [
{"from_route": "/old-page", "to_route": "new-page"},
]Correct: Use website_redirects for URL redirects (returns proper 301/304):
website_redirects = [
{"source": "/old-page", "target": "/new-page"},
]AP-6: Business Logic in www/ Controllers
Wrong: Putting validation, calculations, or data mutations in www/*.py.
Correct: Keep business logic in DocType controllers or whitelisted methods. The www/ controller is for presentation context only.
AP-7: Skipping CSRF Protection
Wrong: Setting ignore_csrf in production site_config.
Correct: NEVER disable CSRF in production. If a third-party needs POST access, use allowed_referrers or API keys instead.
AP-8: Not Extending templates/web.html
Wrong: Writing standalone HTML without extending the base template.
<html><body><h1>My Page</h1></body></html>Correct: ALWAYS extend templates/web.html for consistent navbar, footer, and asset loading:
{% extends "templates/web.html" %}
{% block page_content %}
<h1>My Page</h1>
{% endblock %}AP-9: Exposing Internal Routes to Guests
Wrong: Using website_route_rules that expose DocType names without checking permissions.
Correct: ALWAYS verify permissions in get_context:
def get_context(context):
if not frappe.has_permission("Project", "read"):
frappe.throw("Not Permitted", frappe.PermissionError)AP-10: Forgetting sitemap Exclusion for Private Pages
Wrong: Login-required portal pages appearing in sitemap.xml.
Correct: Set context.sitemap = 0 for all authenticated-only pages.
Website Page Type — Decision Tree
Which Page Type Do I Need?
START: What is the use case?
│
├── Static informational content (About, Terms, FAQ)?
│ ├── Will it be edited by non-developers?
│ │ ├── YES → Web Page DocType (WYSIWYG editor, no code needed)
│ │ └── NO → www/ portal page (.html + .py controller)
│ └── Does it need dynamic data from the database?
│ ├── YES → www/ portal page with get_context()
│ └── NO → Web Page DocType or static .html in www/
│
├── External users need to submit data?
│ ├── Simple form (< 15 fields, no child tables)?
│ │ └── Web Form (built-in, no code needed)
│ ├── Complex form (child tables, multi-step, file uploads)?
│ │ └── Web Form + Custom Script OR custom www/ page with frappe.call
│ └── Need full CRUD (create, read, update, delete)?
│ └── has_web_view on DocType + portal page
│
├── List of records visible on website (articles, products, events)?
│ └── has_web_view on DocType
│ ├── Add `published` (Check) and `route` (Data) fields
│ ├── Create .html templates in doctype directory
│ └── Register in hooks.py: website_generators = ["DocType"]
│
├── Blog or news section?
│ └── Blog Post + Blog Category (built-in)
│ ├── NEVER build custom blog — use the built-in system
│ └── Customize via Blog Post web template override
│
├── Dynamic URL with parameters (/projects/<name>)?
│ └── website_route_rules in hooks.py
│ ├── Maps URL pattern to www/ page or DocType
│ └── Parameters available via frappe.form_dict
│
└── Full custom application page?
└── www/ portal page with .html + .py + .js + .css
├── Use get_context() for server-side data
├── Use .js for client-side interactivity
└── ALWAYS extend templates/web.html for consistent layoutWeb Form vs Portal Page vs Web Page
| Feature | Web Page | Web Form | Portal Page (www/) |
|---|---|---|---|
| Created by | UI (no code) | UI (no code) | Code (.html/.py) |
| Guest access | Yes | Configurable | Configurable |
| Data submission | No | Yes | Custom (frappe.call) |
| Custom logic | Limited | Events + server script | Full Python + JS |
| Child tables | N/A | Limited (v15+) | Full control |
| SEO meta tags | Built-in fields | Limited | Via context.metatags |
| Version control | No (in DB) | No (in DB) | Yes (in app code) |
| Multi-environment | Export as fixture | Export as fixture | Deployed with app |
| Best for | Marketing pages | Simple data collection | Complex portals |
Static vs Dynamic Page Decision
Does the page content change per user or per request?
├── YES → Dynamic page
│ ├── ALWAYS set context.no_cache = 1
│ ├── ALWAYS use get_context() for data
│ └── Consider: will this scale? Cache where possible.
└── NO → Static page
├── Let Frappe cache it (default behavior)
├── Use Web Page DocType for non-developer editing
└── NEVER set no_cache on truly static pages (hurts performance)Website Examples — Complete Code
Example 1: Simple Static Portal Page
myapp/www/
├── about.html
├── about.py
└── about.css<!-- about.html -->
{% extends "templates/web.html" %}
{% block page_content %}
<div class="container my-5">
<h1>{{ title }}</h1>
<p class="lead">{{ tagline }}</p>
<div>{{ description }}</div>
</div>
{% endblock %}# about.py
import frappe
def get_context(context):
settings = frappe.get_doc("About Us Settings")
context.title = "About Us"
context.tagline = settings.company_description
context.description = settings.company_history
context.metatags = {
"title": "About Us | My Company",
"description": settings.company_description[:160],
}Example 2: Web Form with Conditional Fields
Create via UI: Web Form → "Job Application"
Fields:
applicant_name(Data, reqd)email(Data, reqd, options: Email)position(Link, options: Job Opening, reqd)cover_letter(Text Editor)resume(Attach)experience_years(Int)
Client Script:
frappe.web_form.on("after_load", function() {
// Hide experience field for internship positions
frappe.web_form.on("position", function() {
let pos = frappe.web_form.get_value("position");
frappe.call({
method: "frappe.client.get_value",
args: {
doctype: "Job Opening",
filters: { name: pos },
fieldname: "designation"
},
callback: function(r) {
if (r.message && r.message.designation === "Intern") {
frappe.web_form.set_df_property("experience_years", "hidden", 1);
} else {
frappe.web_form.set_df_property("experience_years", "hidden", 0);
}
}
});
});
});Example 3: SEO-Optimized Blog Configuration
# hooks.py
website_context = {
"favicon": "/assets/myapp/images/favicon.ico",
}
update_website_context = "myapp.website.context"
website_route_rules = [
{"from_route": "/blog/category/<category>", "to_route": "blog_category"},
]# myapp/website.py
def context(ctx):
ctx.metatags = ctx.get("metatags", {})
ctx.metatags.setdefault("og:site_name", "My Company Blog")
ctx.metatags.setdefault("twitter:site", "@mycompany")Example 4: Protected Portal with Sidebar
# myapp/www/portal/index.py
import frappe
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login"
raise frappe.Redirect
context.no_cache = 1
context.show_sidebar = True
context.title = "Customer Portal"
context.sidebar_items = [
{"label": "Orders", "url": "/portal/orders", "active": True},
{"label": "Invoices", "url": "/portal/invoices"},
{"label": "Support", "url": "/portal/tickets"},
]<!-- myapp/www/portal/index.html -->
{% extends "templates/web.html" %}
{% block page_content %}
<div class="portal-welcome">
<h2>{{ _("Welcome, {0}").format(frappe.session.user) }}</h2>
<div class="row">
{% for item in sidebar_items %}
<div class="col-md-4">
<a href="{{ item.url }}" class="card p-3">
<h4>{{ item.label }}</h4>
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}Example 5: Website Settings Configuration
Via the Website Settings DocType:
- Home Page:
home(route of homepage) - Brand Image: Company logo for navbar
- Favicon: Browser tab icon
- Navbar Items: Top Bar Item child table (label + URL)
- Footer Items: Footer links child table
- Banner HTML: Custom HTML above navbar
- Head HTML: Injected into
<head>(analytics, custom fonts) - Footer Address: Company address in footer
- Google Analytics ID: UA-XXXXX or G-XXXXX
Example 6: Custom 404 Page
# hooks.py
website_catch_all = "myapp.www.not_found.handler"
# myapp/www/not_found.py
import frappe
def handler(path):
return frappe.get_template("myapp/www/404.html").render({
"path": path,
"title": "Page Not Found",
})Website Generators — Portal Generators, Blog System & Custom Routing
Reference for frappe-impl-website. Covers how DocTypes auto-generate web pages, the blog system, and advanced routing patterns.
Source: Frappe Portal Pages, Frappe Hooks API
---
1. Portal Generators — DocType Web Views
Enabling has_web_view on a DocType
When a DocType has has_web_view = 1, each document becomes a publicly accessible web page. The DocType controller MUST extend WebsiteGenerator instead of Document.
Required DocType fields:
| Field | Type | Purpose |
|---|---|---|
route | Data (hidden) | Auto-generated URL slug |
published | Check | Controls public visibility |
Controller setup:
from frappe.website.website_generator import WebsiteGenerator
class Article(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/article.html",
condition_field="published",
page_title_field="title",
)
def get_context(self, context):
# Add custom context for the web page
context.related_articles = frappe.get_all(
"Article",
filters={"published": 1, "name": ("!=", self.name)},
fields=["title", "route", "image"],
order_by="creation desc",
limit=5,
)Rule: ALWAYS include both route and published fields. NEVER expose documents without a condition_field check.
The website Dict Properties
| Property | Type | Description |
|---|---|---|
template | str | Path to the Jinja template for rendering |
condition_field | str | Field name that must be truthy for the page to be visible (typically "published") |
page_title_field | str | Field used as the HTML <title> |
no_cache | bool | Disable caching for this generator |
no_sitemap | bool | Exclude from sitemap.xml |
parent_website_route | str | Parent route prefix for breadcrumbs |
The website_generators Hook
Register DocTypes as website generators in hooks.py:
# hooks.py
website_generators = ["Article", "Product", "FAQ"]This tells Frappe to include these DocTypes in route resolution and sitemap generation.
What happens when registered: 1. Frappe creates routes for each document where condition_field is truthy 2. Documents appear in /sitemap.xml 3. Route conflicts are resolved by app installation order (last installed wins)
Route Generation
Routes are auto-generated from the document name (slugified). You can customize:
class Article(WebsiteGenerator):
def before_save(self):
# Custom route pattern: /articles/2024/my-article-title
if not self.route:
from frappe.utils import slugify
year = self.creation.year if self.creation else frappe.utils.now_datetime().year
self.route = f"articles/{year}/{slugify(self.title)}"Rule: ALWAYS validate route uniqueness. Frappe raises frappe.DuplicateEntryError on duplicate routes.
Template Structure
Templates for generators live in the app's templates/generators/ directory:
myapp/
├── templates/
│ └── generators/
│ ├── article.html # Single article page
│ └── article_row.html # List item template (optional)Single item template (`article.html`):
{% extends "templates/web.html" %}
{% block page_content %}
<div class="article-page">
<h1>{{ doc.title }}</h1>
<p class="text-muted">{{ frappe.utils.format_date(doc.creation) }}</p>
<div class="article-content">
{{ doc.content }}
</div>
{% if related_articles %}
<h3>Related Articles</h3>
<ul>
{% for article in related_articles %}
<li><a href="/{{ article.route }}">{{ article.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}List row template (`article_row.html`):
<div class="web-list-item">
<a href="/{{ doc.route }}">
<h4>{{ doc.title }}</h4>
<p>{{ doc.description | truncate(150) }}</p>
</a>
</div>The get_context() Method Pattern
get_context is the standard hook for injecting data into web page templates.
For portal pages (www/ files):
# myapp/www/dashboard.py
import frappe
def get_context(context):
context.title = "Dashboard"
context.no_cache = 1
context.show_sidebar = True
# Fetch data for the template
context.orders = frappe.get_all(
"Sales Order",
filters={"customer": frappe.session.user},
fields=["name", "grand_total", "status"],
order_by="creation desc",
limit=20,
)For WebsiteGenerator DocTypes:
class Product(WebsiteGenerator):
def get_context(self, context):
# 'self' is the document, also available as 'doc' in template
context.variants = frappe.get_all(
"Product Variant",
filters={"parent_product": self.name, "published": 1},
fields=["name", "title", "price", "route"],
)
context.metatags = {
"title": self.title,
"description": self.meta_description or self.title,
"image": self.image,
}
# Parents for breadcrumb trail
context.parents = [
{"name": "Products", "route": "/products"},
]Rule: ALWAYS set context.no_cache = 1 for pages with user-specific data. NEVER serve cached pages with personal information.
---
2. Blog System
Core DocTypes
| DocType | Purpose |
|---|---|
| Blog Post | Individual blog article |
| Blog Category | Grouping/taxonomy for posts |
| Blog Settings | Global blog configuration |
| Blogger | Author profile |
Blog Post Structure
Blog Post has these key fields:
| Field | Type | Notes |
|---|---|---|
title | Data | Post title |
blog_category | Link | Required — links to Blog Category |
blogger | Link | Author (Blogger DocType) |
published | Check | Controls visibility |
published_on | Date | REQUIRED for RSS and sorting |
content_type | Select | "Markdown" or "Rich Text" |
content | Text Editor | The post body (Rich Text) |
content_md | Code | The post body (Markdown) |
meta_title | Data | SEO title override |
meta_description | Small Text | SEO description |
meta_image | Attach Image | Social sharing image |
featured | Check | Mark as featured (v15+) |
Route Pattern
Blog routes follow this auto-generated pattern:
/blog/{blog-category-slug}/{post-slug}Example: A post titled "Getting Started" in category "Tutorials" generates /blog/tutorials/getting-started.
The blog listing page is at /blog.
Blog Settings
Configure via Blog Settings DocType:
| Setting | Effect |
|---|---|
blog_title | Displayed on the blog listing page |
blog_introduction | Introductory text on listing page |
browse_by_category | Show category filter in sidebar |
comment_limit | Max comments per post (0 = unlimited) |
allow_guest_to_comment | Let unauthenticated users comment |
RSS Feeds
Frappe auto-generates RSS feeds:
- Blog RSS:
/blog/feed— includes all published Blog Posts - RSS includes: title, description, published_on, link, author
Rule: ALWAYS set published_on date on Blog Posts. Posts without this date are excluded from RSS feeds and may sort incorrectly.
Custom RSS: For custom DocType RSS, implement in your controller:
class Article(WebsiteGenerator):
def get_feed(self):
return self.titleCustom Blog Templates
Override the default blog templates by placing files in your app:
myapp/templates/
├── includes/
│ └── blog/ # Override blog components
│ ├── blog.html # Main blog listing page
│ └── blog_post.html # Individual post pageOr use base_template_map in hooks.py for route-specific templates:
base_template_map = {
r"blog.*": "myapp/templates/custom_blog_base.html"
}---
3. Custom Web Templates for DocTypes
Web Template DocType (v13+)
Web Templates are reusable, configurable components for building web pages:
# Creating a Web Template programmatically
web_template = frappe.get_doc({
"doctype": "Web Template",
"name": "Product Card",
"template": """
<div class="product-card">
<img src="{{ image }}" alt="{{ title }}">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<span class="price">{{ price }}</span>
</div>
""",
"fields": [
{"fieldname": "title", "fieldtype": "Data", "label": "Title"},
{"fieldname": "description", "fieldtype": "Text", "label": "Description"},
{"fieldname": "image", "fieldtype": "Attach Image", "label": "Image"},
{"fieldname": "price", "fieldtype": "Data", "label": "Price"},
]
})Web Templates are used inside Web Pages via the page builder interface, not directly in generators. For generator DocTypes, use standard Jinja templates (see Section 1).
---
4. website_route_rules — Custom Routing
Basic Syntax
# hooks.py
website_route_rules = [
# Simple parameterized route
{"from_route": "/projects/<name>", "to_route": "projects/project"},
# Path parameter (captures slashes)
{"from_route": "/kb/<path:name>", "to_route": "knowledge-base"},
# Multiple parameters
{"from_route": "/shop/<category>/<product>", "to_route": "shop/product"},
]How Route Rules Work
1. from_route — the public URL pattern (what users see) 2. to_route — the internal page that handles the request (www/ page or Web Page name) 3. Parameters from from_route become frappe.form_dict values in the handler
Handler example:
# myapp/www/projects/project.py
import frappe
def get_context(context):
project_name = frappe.form_dict.name
project = frappe.get_doc("Project", project_name)
if not project.published:
raise frappe.DoesNotExistError
context.project = project
context.title = project.project_name
context.no_cache = 1Related Routing Hooks
# hooks.py
# Simple redirects (use INSTEAD of route_rules for redirects)
website_redirects = [
{"source": "/old-page", "target": "/new-page"},
{"source": r"/docs(/.*)?", "target": r"https://docs.example.com\1"},
]
# Dynamic route resolver
website_path_resolver = "myapp.routing.resolve_path"
# Dynamic routes beyond standard pages
get_web_pages_with_dynamic_routes = "myapp.routing.get_dynamic_routes"
# Custom 404 handler
website_catch_all = "not_found"Rule: Use website_route_rules for parameterized routes. Use website_redirects for simple URL redirects. NEVER use route_rules when a redirect suffices.
---
5. Common Patterns
Pattern A: Product Catalog
# hooks.py
website_generators = ["Product"]
# product.py (DocType controller)
class Product(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/product.html",
condition_field="published",
page_title_field="product_name",
)
def get_context(self, context):
context.variants = frappe.get_all(
"Product Variant",
filters={"product": self.name, "published": 1},
fields=["name", "variant_name", "price", "image"],
)
context.parents = [{"name": "Products", "route": "/products"}]
# hooks.py — listing page route
website_route_rules = [
{"from_route": "/products", "to_route": "products"},
{"from_route": "/products/<category>", "to_route": "products"},
]Pattern B: Knowledge Base
# hooks.py
website_generators = ["KB Article"]
website_route_rules = [
{"from_route": "/kb/<path:name>", "to_route": "knowledge-base"},
]
# kb_article.py
class KBArticle(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/kb_article.html",
condition_field="published",
page_title_field="title",
)
def get_context(self, context):
context.siblings = frappe.get_all(
"KB Article",
filters={"category": self.category, "published": 1},
fields=["title", "route"],
)
context.parents = [
{"name": "Knowledge Base", "route": "/kb"},
{"name": self.category, "route": f"/kb/{frappe.scrub(self.category)}"},
]Pattern C: Customer Portal
# hooks.py
role_home_page = {
"Customer": "portal/dashboard",
}
portal_menu_items = [
{"title": "Dashboard", "route": "/portal/dashboard", "role": "Customer"},
{"title": "Orders", "route": "/portal/orders", "role": "Customer"},
{"title": "Invoices", "route": "/portal/invoices", "role": "Customer"},
{"title": "Support", "route": "/portal/tickets", "role": "Customer"},
]
# myapp/www/portal/dashboard.py
import frappe
def get_context(context):
if frappe.session.user == "Guest":
frappe.throw("Login required", frappe.PermissionError)
context.title = "My Dashboard"
context.no_cache = 1
context.show_sidebar = True
customer = frappe.db.get_value("Customer", {"user": frappe.session.user})
context.orders = frappe.get_all(
"Sales Order",
filters={"customer": customer, "docstatus": 1},
fields=["name", "grand_total", "status", "transaction_date"],
order_by="transaction_date desc",
limit=10,
)---
Anti-Patterns
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
WebsiteGenerator without condition_field | All documents exposed publicly | ALWAYS set condition_field="published" |
Missing route field on has_web_view DocType | Routes not generated, pages 404 | ALWAYS add hidden route Data field |
Forgetting website_generators hook entry | DocType pages unreachable | ALWAYS register in hooks.py |
Blog Post without published_on date | Missing from RSS, wrong sort order | ALWAYS set published_on |
Using website_route_rules for simple redirects | Unnecessary complexity | Use website_redirects instead |
| Hardcoded routes in templates | Breaks when route changes | Use {{ doc.route }} or frappe.utils.get_url() |
| Cached pages with user-specific data | Data leaks between users | ALWAYS set no_cache = 1 for personalized pages |
Website Workflows — Extended
Complete Portal Page with Authentication
# myapp/www/dashboard.py
import frappe
def get_context(context):
# ALWAYS check login for protected pages
if frappe.session.user == "Guest":
frappe.throw("Please log in", frappe.AuthenticationError)
context.no_cache = 1
context.show_sidebar = True
context.title = "My Dashboard"
user = frappe.session.user
context.orders = frappe.get_all(
"Sales Order",
filters={"owner": user, "docstatus": 1},
fields=["name", "transaction_date", "grand_total", "status"],
order_by="transaction_date desc",
limit=20,
)<!-- myapp/www/dashboard.html -->
{% extends "templates/web.html" %}
{% block page_content %}
<div class="container">
<h2>{{ _("My Orders") }}</h2>
{% if orders %}
<table class="table">
<thead>
<tr>
<th>{{ _("Order") }}</th>
<th>{{ _("Date") }}</th>
<th>{{ _("Total") }}</th>
<th>{{ _("Status") }}</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td><a href="/app/sales-order/{{ order.name }}">{{ order.name }}</a></td>
<td>{{ frappe.format_date(order.transaction_date) }}</td>
<td>{{ frappe.format_currency(order.grand_total) }}</td>
<td>{{ order.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">{{ _("No orders found.") }}</p>
{% endif %}
</div>
{% endblock %}Web Form with File Upload and Redirect
1. Create Web Form "Support Ticket" linked to DocType "Issue" 2. Add fields: subject (Data), description (Text Editor), attachment (Attach) 3. Set Success URL to /thank-you 4. Enable Allow Attachment 5. Client Script:
frappe.web_form.on("before_submit", function() {
let subject = frappe.web_form.get_value("subject");
if (subject && subject.length < 5) {
frappe.msgprint("Subject must be at least 5 characters");
return false;
}
});has_web_view Complete Setup
Step 1: DocType Configuration
- Check: Has Web View, Allow Guest to View
- Route prefix:
articles - Add fields:
route(Data, hidden),published(Check),meta_title(Data),meta_description(Small Text)
Step 2: Controller
# myapp/myapp/doctype/article/article.py
import frappe
from frappe.website.website_generator import WebsiteGenerator
class Article(WebsiteGenerator):
website = frappe._dict(
template="templates/generators/article.html",
condition_field="published",
page_title_field="title",
)
def get_context(self, context):
context.metatags = {
"title": self.meta_title or self.title,
"description": self.meta_description or self.title,
}
# Related articles
context.related = frappe.get_all(
"Article",
filters={"published": 1, "name": ("!=", self.name)},
fields=["title", "route", "published_on"],
order_by="published_on desc",
limit=3,
)Step 3: Templates
<!-- myapp/templates/generators/article.html -->
{% extends "templates/web.html" %}
{% block page_content %}
<article>
<h1>{{ doc.title }}</h1>
<p class="text-muted">{{ frappe.format_date(doc.published_on) }}</p>
<div>{{ doc.content }}</div>
</article>
{% if related %}
<h3>{{ _("Related Articles") }}</h3>
<ul>
{% for r in related %}
<li><a href="/{{ r.route }}">{{ r.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}Step 4: hooks.py
website_generators = ["Article"]Website Route Rules — Dynamic Pages
# hooks.py
website_route_rules = [
{"from_route": "/catalog/<category>", "to_route": "catalog"},
{"from_route": "/catalog/<category>/<item>", "to_route": "catalog/item"},
]# myapp/www/catalog.py
import frappe
def get_context(context):
category = frappe.form_dict.get("category")
if not category:
frappe.throw("Category not found", frappe.DoesNotExistError)
context.category = frappe.get_doc("Item Group", category)
context.items = frappe.get_all(
"Item",
filters={"item_group": category, "show_in_website": 1},
fields=["item_name", "route", "image", "description"],
)
context.title = context.category.name
context.no_cache = 1Website Context Hooks
# hooks.py — inject global context
website_context = {
"favicon": "/assets/myapp/images/favicon.ico",
"splash_image": "/assets/myapp/images/logo.svg",
}
# For dynamic context
update_website_context = "myapp.overrides.website_context"
# myapp/overrides.py
def website_context(context):
context.company_name = frappe.db.get_single_value("Website Settings", "app_name")
context.footer_links = frappe.get_all(
"Top Bar Item",
filters={"parent": "Website Settings", "parentfield": "footer_items"},
fields=["label", "url"],
)Override Base Template
# hooks.py — global override
base_template = "myapp/templates/custom_base.html"
# Route-specific override
base_template_map = {
r"docs.*": "myapp/templates/docs_base.html",
r"blog.*": "myapp/templates/blog_base.html",
}