Skip to content

Website & E-Commerce

Frappe ships with a full website framework baked into the core, and ERPNext extends it with e-commerce capabilities — product catalogs, shopping carts, checkout flows, and customer portals. If you’ve built a storefront on top of an Express app with a separate templating layer, this is the part where Frappe hands you the templating, routing, and order plumbing for free. For ScoopJoy, this means franchise outlets and retail customers can browse the catalog and place orders straight from the site.

Every Frappe app has a www directory that maps directly to website routes — the file path is the URL. There’s no router config to write; dropping a file in www/ publishes a page.

  • Directoryapps/scoopjoy/scoopjoy/
    • Directorywww/
      • index.html /
      • about.html /about
      • menu.html /menu
      • Directoryfranchise/
        • apply.html /franchise/apply
        • locations.html /franchise/locations

Pages can be .html (Jinja templates) or .md (Markdown). They automatically extend frappe/templates/web.html, which provides the navbar, footer, and Bootstrap styling — so a bare page already looks like part of the site.

A page template overrides Jinja blocks like title and page_content. Notice the {{ ... }} interpolations and the {% for %} loop — standard Jinja2, the same templating you’d reach for with Nunjucks or EJS in a Node app.

apps/scoopjoy/scoopjoy/www/menu.html
{%- block title -%}Our Ice Cream Menu{%- endblock -%}
{%- block page_content -%}
<div class="container my-5">
<h1>ScoopJoy Ice Cream Menu</h1>
<p class="lead">Discover our franchise-wide flavors</p>
<div class="row">
{% for item in items %}
<div class="col-md-4 mb-4">
<div class="card h-100">
{% if item.image %}
<img src="{{ item.image }}" class="card-img-top" alt="{{ item.item_name }}">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ item.item_name }}</h5>
<p class="card-text">{{ item.description or "" }}</p>
<p class="font-weight-bold">{{ frappe.format_value(item.standard_rate, "Currency") }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{%- endblock -%}

The context — the variables the template renders — comes from a Python file with the same name sitting next to it. Frappe calls its get_context() automatically. This is the Frappe equivalent of a route handler that fetches data then renders a view, except the wiring is by filename, not a route declaration.

apps/scoopjoy/scoopjoy/www/menu.py
import frappe
def get_context(context):
"""Provide ice cream items to the menu page."""
context.items = frappe.get_all(
"Item",
filters={
"item_group": ["in", ["Ice Cream Cones", "Ice Cream Cups", "Sundaes"]],
"show_in_website": 1,
"disabled": 0
},
fields=["item_name", "description", "image", "standard_rate", "route"],
order_by="item_name asc"
)
context.title = "Our Ice Cream Menu"
context.no_cache = 0

Global site chrome — home page, branding, top navigation — lives in the Website Settings single DocType. You can configure it from the Desk UI, or script it as part of your app’s setup:

apps/scoopjoy/scoopjoy/setup/website.py
import frappe
def configure_franchise_website():
"""Set up website basics for the franchise site."""
ws = frappe.get_single("Website Settings")
ws.home_page = "home"
ws.brand_html = '<img src="/assets/scoopjoy/images/logo.png" style="height:30px"> ScoopJoy'
ws.copyright = "ScoopJoy Ice Cream Franchise"
ws.disable_signup = 0 # Allow customer registration
# Top navigation bar
ws.top_bar_items = []
for label, url in [
("Menu", "/menu"),
("Locations", "/franchise/locations"),
("Order Online", "/shop"),
("Franchise Inquiry", "/franchise-application"),
]:
ws.append("top_bar_items", {"label": label, "url": url})
ws.save(ignore_permissions=True)
frappe.db.commit()

To make an Item show up on the storefront, set show_in_website = 1 on it. The website fields (image, web_long_description) drive how it renders in the catalog.

apps/scoopjoy/scoopjoy/setup/catalog.py
import frappe
def publish_ice_cream_catalog():
"""Publish ice cream items to the website catalog."""
items_to_publish = [
{
"item_code": "IC-CONE-VAN",
"item_name": "Vanilla Cone",
"item_group": "Ice Cream Cones",
"standard_rate": 80,
"image": "/files/vanilla-cone.jpg",
"web_long_description": (
"<p>Classic vanilla soft-serve in a crispy waffle cone. "
"Made with real Madagascar vanilla beans.</p>"
)
},
{
"item_code": "IC-CUP-CHOC",
"item_name": "Chocolate Cup",
"item_group": "Ice Cream Cups",
"standard_rate": 100,
"image": "/files/chocolate-cup.jpg",
"web_long_description": (
"<p>Rich Belgian chocolate ice cream served in a cup "
"with chocolate shavings on top.</p>"
)
},
]
for item_data in items_to_publish:
if frappe.db.exists("Item", item_data["item_code"]):
item = frappe.get_doc("Item", item_data["item_code"])
else:
item = frappe.new_doc("Item")
item.item_code = item_data["item_code"]
item.item_name = item_data["item_name"]
item.item_group = item_data["item_group"]
item.standard_rate = item_data["standard_rate"]
# Website publishing fields
item.show_in_website = 1
item.image = item_data.get("image")
item.web_long_description = item_data.get("web_long_description")
item.save(ignore_permissions=True)
frappe.db.commit()

Item Groups double as website categories. Set show_in_website = 1 on the Item Group and it becomes a browsable category landing page.

apps/scoopjoy/scoopjoy/setup/categories.py
import frappe
def setup_website_categories():
"""Create item group categories visible on the website."""
categories = [
{
"name": "Ice Cream Cones",
"parent": "All Item Groups",
"description": "Crispy cones with your favorite flavors",
"image": "/files/category-cones.jpg"
},
{
"name": "Ice Cream Cups",
"parent": "All Item Groups",
"description": "Scooped perfection in a cup",
"image": "/files/category-cups.jpg"
},
{
"name": "Sundaes",
"parent": "All Item Groups",
"description": "Loaded sundaes for the adventurous",
"image": "/files/category-sundaes.jpg"
}
]
for cat in categories:
if frappe.db.exists("Item Group", cat["name"]):
ig = frappe.get_doc("Item Group", cat["name"])
else:
ig = frappe.new_doc("Item Group")
ig.item_group_name = cat["name"]
ig.parent_item_group = cat["parent"]
ig.show_in_website = 1
ig.description = cat["description"]
ig.image = cat.get("image")
ig.save(ignore_permissions=True)
frappe.db.commit()

The cart, checkout, and pricing behavior come from the Webshop Settings single DocType (post-v15). Note payment_gateway_account — that’s where this ties into the gateway you wired up in Chapter 22.

apps/scoopjoy/scoopjoy/setup/webshop.py
import frappe
def configure_shopping_cart():
"""Enable and configure the e-commerce shopping cart."""
settings = frappe.get_single("Webshop Settings")
settings.enabled = 1
settings.company = "ScoopJoy Pvt Ltd"
settings.default_customer_group = "Retail"
settings.quotation_series = "SAL-QTN-"
settings.enable_checkout = 1
settings.show_price = 1
settings.show_stock_availability = 1
settings.show_quantity_in_website = 0
settings.allow_items_not_in_stock = 0
# Payment gateway for checkout
settings.payment_gateway_account = "Razorpay - INR"
# Price list for website
settings.price_list = "Standard Selling"
settings.save(ignore_permissions=True)
frappe.db.commit()

The checkout flow turns an anonymous browse into a real ERPNext sales document. The cart is a draft Quotation under the hood, which becomes a Sales Order on confirmation:

Webshop checkout flow
Rendering diagram…

Web Forms let you publish a public-facing form backed by any DocType, without giving the submitter Desk access. Think of it as a hosted form that writes straight into your data model — handy for lead capture, applications, and surveys.

Here’s a franchise application form backed by a custom DocType. Defining it in code (rather than clicking through the UI) makes it reproducible across sites:

apps/scoopjoy/scoopjoy/setup/franchise_webform.py
import frappe
def create_franchise_application_webform():
"""Create a 'Franchise Application' web form backed by a custom DocType."""
if frappe.db.exists("Web Form", "franchise-application"):
return
wf = frappe.get_doc({
"doctype": "Web Form",
"title": "Franchise Application",
"route": "franchise-application",
"doc_type": "Franchise Application", # Custom DocType
"published": 1,
"allow_edit": 0,
"allow_multiple": 0,
"login_required": 0,
"show_sidebar": 0,
"introduction_text": (
"<h3>Apply to Become a ScoopJoy Franchise Partner</h3>"
"<p>Fill out the form below and our team will get in touch "
"within 48 hours.</p>"
),
"success_message": (
"Thank you for your application! "
"We will review it and contact you shortly."
),
"success_url": "/franchise/thank-you",
"web_form_fields": [
{
"fieldname": "applicant_name",
"fieldtype": "Data",
"label": "Full Name",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email Address",
"options": "Email",
"reqd": 1
},
{
"fieldname": "phone",
"fieldtype": "Data",
"label": "Phone Number",
"options": "Phone",
"reqd": 1
},
{
"fieldname": "city",
"fieldtype": "Data",
"label": "Proposed City / Location",
"reqd": 1
},
{
"fieldname": "investment_budget",
"fieldtype": "Select",
"label": "Investment Budget",
"options": "\n5-10 Lakhs\n10-25 Lakhs\n25-50 Lakhs\n50+ Lakhs",
"reqd": 1
},
{
"fieldname": "experience",
"fieldtype": "Small Text",
"label": "Relevant Business Experience"
},
{
"fieldname": "business_plan",
"fieldtype": "Attach",
"label": "Upload Business Plan (optional)"
},
{
"fieldname": "message",
"fieldtype": "Text",
"label": "Additional Message"
}
]
})
wf.insert(ignore_permissions=True)
frappe.db.commit()

Portal pages give logged-in customers (or suppliers) access to their own data. ERPNext ships built-in portal views for Sales Orders, Invoices, and more, and you can add your own.

The portal sidebar is driven by portal_menu_items in your app’s hooks.py — each entry is gated by a role so only the right users see the link:

apps/scoopjoy/scoopjoy/hooks.py
portal_menu_items = [
{"title": "My Orders", "route": "/orders", "role": "Customer"},
{"title": "My Invoices", "route": "/invoices", "role": "Customer"},
{"title": "My Addresses", "route": "/addresses", "role": "Customer"},
{"title": "My Issues", "route": "/issues", "role": "Customer"},
]

The page template is ordinary Jinja, but the context function does the access-control work — bouncing guests and scoping the query to the Customer linked to the current user. The badge color uses an inline Jinja conditional: {{ 'success' if order.status == 'Completed' else 'warning' }}.

apps/scoopjoy/scoopjoy/www/orders.html
{%- block title -%}My Orders{%- endblock -%}
{%- block page_content -%}
<div class="container my-4">
<h2>My Order History</h2>
{% if orders %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Order #</th>
<th>Date</th>
<th>Items</th>
<th>Total</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td><a href="/orders/{{ order.name }}">{{ order.name }}</a></td>
<td>{{ frappe.format_value(order.transaction_date, "Date") }}</td>
<td>{{ order.total_qty }} items</td>
<td>{{ frappe.format_value(order.grand_total, "Currency") }}</td>
<td>
<span class="badge badge-{{ 'success' if order.status == 'Completed' else 'warning' }}">
{{ order.status }}
</span>
</td>
<td>
{% if order.per_billed < 100 %}
<a href="/api/method/erpnext.accounts.doctype.payment_request.payment_request.make_payment_request?dn={{ order.name }}&dt=Sales Order"
class="btn btn-sm btn-primary">Pay Now</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">You haven't placed any orders yet.</p>
{% endif %}
</div>
{%- endblock -%}

The context guards against Guest, resolves the Customer from the logged-in user via their Contact, and lists only that customer’s submitted orders (docstatus: 1):

apps/scoopjoy/scoopjoy/www/orders.py
import frappe
def get_context(context):
"""Provide order data for the customer portal."""
if frappe.session.user == "Guest":
frappe.throw("Please log in to view your orders", frappe.PermissionError)
customer = get_customer_for_user(frappe.session.user)
if not customer:
context.orders = []
return
context.orders = frappe.get_all(
"Sales Order",
filters={
"customer": customer,
"docstatus": 1
},
fields=[
"name", "transaction_date", "grand_total",
"total_qty", "status", "per_billed", "currency"
],
order_by="transaction_date desc",
limit_page_length=50
)
context.title = "My Orders"
def get_customer_for_user(user):
"""Get the Customer linked to a portal user."""
customer = frappe.db.get_value(
"Contact",
{"user": user},
"link_name",
)
return customer

For items with variants — say a flavor offered in several sizes — you can build a custom product template that swaps price and image client-side, then posts to the Webshop cart endpoint. The template renders the base item plus a button per variant:

apps/scoopjoy/scoopjoy/www/product.html
{%- block title -%}{{ item.item_name }}{%- endblock -%}
{%- block page_content -%}
<div class="container my-5">
<div class="row">
<div class="col-md-6">
<img src="{{ item.image or '/assets/scoopjoy/images/placeholder.jpg' }}"
class="img-fluid rounded" alt="{{ item.item_name }}" id="product-image">
</div>
<div class="col-md-6">
<h1>{{ item.item_name }}</h1>
<p class="text-muted">{{ item.item_group }}</p>
<h3 id="product-price" class="my-3">
{{ frappe.format_value(item.standard_rate, "Currency") }}
</h3>
{{ item.web_long_description or "" }}
{% if variants %}
<div class="my-4">
<label class="font-weight-bold">Choose Size:</label>
<div class="d-flex gap-2 flex-wrap mt-2">
{% for variant in variants %}
<button class="btn btn-outline-primary variant-btn"
data-item="{{ variant.item_code }}"
data-price="{{ variant.standard_rate }}"
data-image="{{ variant.image or '' }}">
{{ variant.variant_label }}
</button>
{% endfor %}
</div>
</div>
{% endif %}
<button class="btn btn-primary btn-lg mt-3" id="add-to-cart"
data-item-code="{{ item.item_code }}">
Add to Cart
</button>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
// Variant selector
document.querySelectorAll(".variant-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".variant-btn").forEach(function (b) {
b.classList.remove("active");
});
this.classList.add("active");
var price = this.dataset.price;
var itemCode = this.dataset.item;
document.getElementById("product-price").textContent = "₹ " + price;
document.getElementById("add-to-cart").dataset.itemCode = itemCode;
if (this.dataset.image) {
document.getElementById("product-image").src = this.dataset.image;
}
});
});
// Add to cart
document.getElementById("add-to-cart").addEventListener("click", function () {
var itemCode = this.dataset.itemCode;
frappe.call({
// verify exact path after installing the Frappe Webshop app
method: "webshop.webshop.shopping_cart.cart.add_to_cart",
args: { item_code: itemCode },
callback: function (r) {
if (!r.exc) {
window.location.href = "/cart";
}
},
});
});
});
</script>
{%- endblock -%}

The context loads the item, rejects anything not published, and — when the item has_variants — fetches each child variant plus its attribute values to build a human-readable label like “Small” or “Large”:

apps/scoopjoy/scoopjoy/www/product.py
import frappe
def get_context(context):
"""Provide product data including variants."""
item_code = frappe.form_dict.get("item") or frappe.form_dict.get("name")
if not item_code:
frappe.throw("Item not specified", frappe.DoesNotExistError)
item = frappe.get_doc("Item", item_code)
if not item.show_in_website:
frappe.throw("This product is not available", frappe.DoesNotExistError)
context.item = item
context.variants = []
if item.has_variants:
# Fetch variants with their attributes
variant_codes = frappe.get_all(
"Item",
filters={
"variant_of": item.item_code,
"show_in_website": 1,
"disabled": 0
},
fields=["item_code", "item_name", "standard_rate", "image"],
order_by="standard_rate asc"
)
for v in variant_codes:
# Get the variant attribute for labeling (e.g., "Small", "Large")
attrs = frappe.get_all(
"Item Variant Attribute",
filters={"parent": v.item_code},
fields=["attribute", "attribute_value"]
)
v["variant_label"] = ", ".join(
a.attribute_value for a in attrs
)
context.variants.append(v)