
Frappe Impl Website
- 23 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-impl-website is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-impl-website
- AI & Agent Building
- AI-coding skill
Frappe Impl Website by the numbers
- 23 all-time installs (skills.sh)
- Ranked #10,032 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-impl-websiteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
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",
}