Skip to content
Skip to the article
In Frappe: 20 articles
Frappe

Custom Pages

Load standalone Vue, React, or Declarative DOM applications on Frappe Page doctypes inside the desk.

Updated
Tags
  • frappe
  • pages
  • javascript
  • vue
Reading time
4 min

Load standalone applications (Vue, Declarative DOM, React, etc.) on Frappe Page doctypes. Pages provide a blank canvas within the Frappe desk, with access to the full Frappe client API.

Page Setup

Page DocType Definition

Create a Page via the standard module structure:

mymodule/page/my_page/
├── __init__.py           # Server-side API endpoints
├── my_page.py            # Empty file (required by Frappe for Page registration)
├── my_page.json          # Page definition
├── my_page.js            # Built bundle (output from build tool)
├── package.json          # Node dependencies (optional, for build step)
├── rollup.config.mjs     # Build configuration (or vite.config.ts)
├── my_query.sql          # External SQL files (optional)
└── src/
    ├── index.mjs         # Entry point
    ├── my_page.mjs       # Root component
    └── components/
        ├── global/       # Reusable components
        └── local/        # Page-specific components

Page JSON

{
    "doctype": "Page",
    "name": "my-page",
    "page_name": "my-page",
    "title": "My Page",
    "module": "My Module",
    "standard": "Yes",
    "roles": [
        {"role": "Sales Manager"},
        {"role": "Sales User"}
    ]
}

Empty Python File

my_page.py must exist (even if empty) for Frappe to register the page:

# Required for Frappe page registration

Mounting the Application

Entry Point Pattern

The entry point hooks into Frappe's page lifecycle:

// src/index.mjs
frappe.pages["my-page"].on_page_load = function (wrapper) {
    // Create the Frappe page container
    var page = frappe.ui.make_app_page({
        parent: wrapper,
        title: "My Page",
        single_column: true,
    });

    // Mount your application into page.main
    mountApp(page.main[0]);
};

page.main[0] is the raw DOM element where your app should render.

With Vue

import { createApp } from "vue";
import MyApp from "./MyApp.vue";
import { FrappeUI } from "frappe-ui";

frappe.pages["my-page"].on_page_load = function (wrapper) {
    var page = frappe.ui.make_app_page({
        parent: wrapper,
        title: "My Page",
        single_column: true,
    });

    const app = createApp(MyApp);
    app.use(FrappeUI);
    app.mount(page.main[0]);
};

With Declarative DOM

import DDOM from "@declarative-dom/lib";
import myPageComponent from "./my_page";

frappe.pages["my-page"].on_page_load = function (wrapper) {
    var page = frappe.ui.make_app_page({
        parent: wrapper,
        title: "My Page",
        single_column: true,
    });

    try {
        DDOM.adoptNode(myPageComponent, page.main[0]);
    } catch (error) {
        console.error("Error initializing page:", error);
    }
};

Frappe Realtime Integration

Listen for server-side progress updates:

// Client: listen for events
frappe.realtime.on("my_progress", (data) => {
    frappe.show_progress(
        data.title,
        data.progress,
        data.total,
        `Current: ${data.current_item}`
    );

    if (data.progress >= data.total) {
        frappe.hide_progress();
    }
});
# Server: publish events
import frappe

frappe.publish_realtime(
    "my_progress",
    {
        "title": "Processing Quotes",
        "progress": i,
        "total": total,
        "current_item": item_name,
    },
    user=frappe.session.user,
)

Server-Side API Endpoints

Place whitelisted API functions in __init__.py:

# mymodule/page/my_page/__init__.py
import json
from pathlib import Path
from typing import Any, TypedDict

import frappe


class ProductOption(TypedDict):
    item_code: str
    item_name: str
    attributes: dict[str, str]


@frappe.whitelist()
def get_product_variants(item_code: str) -> list[ProductOption]:
    """Load and execute an external SQL query."""
    query = (Path(__file__).parent / "variants.sql").read_text()
    raw = frappe.db.sql(query, {"item_code": item_code}, as_dict=True)
    return json.loads(raw[0]["result"]) if raw else []


@frappe.whitelist()
def create_proposal_with_quotes(
    combinations_data: str,
    customer_name: str,
    sales_partner: str,
) -> dict:
    """Create a proposal and multiple linked quotes."""
    combinations = json.loads(combinations_data)
    # ... creation logic
    return {"status": "success", "proposal": proposal.name}

External SQL Files

Complex queries live in separate .sql files:

my_page/
├── bom_template_options.sql    # CTE query returning JSON
├── item_hierarchy.sql          # Nested set hierarchy query
└── variants.sql                # Variant attributes query

Load and execute:

query = (Path(__file__).parent / "variants.sql").read_text()
result = frappe.db.sql(query, {"item_code": item_code}, as_dict=True)

Build Pipeline

Rollup (Simple, No Vue SFCs)

// rollup.config.mjs
export default {
    input: "src/index.mjs",
    output: {
        file: "my_page.js",     // Output alongside the page definition
        format: "umd",
        name: "MyPage",
        inlineDynamicImports: true,
    },
    plugins: [nodeResolve({ browser: true })],
};

Development with Live Reload

Both Vite and Rollup can publish to Frappe's Redis event bus for live reload:

async function notifyFrappeReload() {
    const { createClient } = require("@redis/client");
    const client = createClient({
        url: process.env.FRAPPE_REDIS_QUEUE || "redis://queue:6379",
    });
    await client.connect();
    await client.publish("events", JSON.stringify({
        event: "build_event",
        message: {
            success: true,
            changed_files: ["my_page.js"],
            live_reload: true,
        },
    }));
    await client.disconnect();
}

See Vite & TypeScript Compilation for the full Vite-based build setup.

Frappe Page API

Useful methods on the page object returned by frappe.ui.make_app_page:

MethodPurpose
page.mainjQuery wrapper for the content area
page.set_title(title)Update page title
page.set_primary_action(label, fn)Primary action button
page.set_secondary_action(label, fn)Secondary action button
page.add_menu_item(label, fn)Add to the "..." menu
page.add_inner_button(label, fn, group)Grouped inner buttons
page.clear_primary_action()Remove primary button

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.