Skip to content

Odoo Web Client Actions Guide: Custom JavaScript Actions

DeployMonkey Team · March 24, 2026 11 min read

What Are Client Actions?

Client actions are custom JavaScript views that replace Odoo's standard form/list/kanban views with entirely custom interfaces. They are rendered client-side using Odoo's OWL framework and can do anything — dashboards, configuration wizards, data visualizations, or specialized workflows that do not fit standard view types.

When to Use Client Actions

  • Custom dashboards with charts, KPIs, and interactive elements
  • Setup wizards with multi-step flows
  • Specialized interfaces (calendar, Gantt alternatives)
  • Integration UIs (payment terminal, barcode scanner)
  • Configuration panels that combine multiple settings

Basic Structure

1. Define the Action in XML

<record id="action_my_dashboard" model="ir.actions.client">
    <field name="name">My Dashboard</field>
    <field name="tag">my_module.dashboard</field>
    <field name="target">main</field>
</record>

<!-- Add to menu -->
<menuitem id="menu_my_dashboard"
    name="Dashboard"
    action="action_my_dashboard"
    parent="menu_root"/>

The tag field links the XML action to a JavaScript component registered in the action registry.

2. Create the OWL Component

/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";

class MyDashboard extends Component {
    static template = "my_module.Dashboard";

    setup() {
        this.orm = useService("orm");
        this.state = useState({
            totalOrders: 0,
            totalRevenue: 0,
            recentOrders: [],
            loading: true,
        });

        onWillStart(async () => {
            await this.loadData();
        });
    }

    async loadData() {
        this.state.loading = true;
        const result = await this.orm.call(
            "sale.order",
            "get_dashboard_data",
            [],
        );
        this.state.totalOrders = result.total_orders;
        this.state.totalRevenue = result.total_revenue;
        this.state.recentOrders = result.recent_orders;
        this.state.loading = false;
    }

    async onRefresh() {
        await this.loadData();
    }
}

registry.category("actions").add("my_module.dashboard", MyDashboard);

3. Create the OWL Template

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
    <t t-name="my_module.Dashboard">
        <div class="o_action">
            <div class="o_dashboard_header d-flex justify-content-between p-3">
                <h2>Sales Dashboard</h2>
                <button class="btn btn-primary" t-on-click="onRefresh">
                    Refresh
                </button>
            </div>
            <div class="o_dashboard_content p-3" t-if="!state.loading">
                <div class="row">
                    <div class="col-md-6">
                        <div class="card">
                            <div class="card-body">
                                <h5>Total Orders</h5>
                                <span class="h2" t-esc="state.totalOrders"/>
                            </div>
                        </div>
                    </div>
                    <div class="col-md-6">
                        <div class="card">
                            <div class="card-body">
                                <h5>Revenue</h5>
                                <span class="h2" t-esc="state.totalRevenue"/>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <div t-else="" class="text-center p-5">
                <i class="fa fa-spinner fa-spin fa-3x"/>
            </div>
        </div>
    </t>
</templates>

Server Communication

Using orm Service

// Call a model method
const result = await this.orm.call(
    "sale.order",      // model
    "get_dashboard_data",  // method
    [],                // args
    {},                // kwargs
);

// Search and read
const orders = await this.orm.searchRead(
    "sale.order",
    [["state", "=", "sale"]],  // domain
    ["name", "amount_total"],  // fields
    { limit: 10, order: "create_date desc" },
);

// Read group
const groups = await this.orm.readGroup(
    "sale.order",
    [["state", "=", "sale"]],
    ["amount_total:sum"],
    ["partner_id"],
);

Using rpc Service

// For custom controller endpoints
this.rpc = useService("rpc");

const data = await this.rpc("/my_module/api/dashboard", {
    date_from: "2026-01-01",
    date_to: "2026-03-24",
});

Available Services

ServicePurpose
ormORM operations (search, read, write, call)
rpcCustom HTTP endpoint calls
actionNavigate to other actions
notificationShow toast notifications
dialogOpen dialog windows
userCurrent user info
companyCurrent company info

Navigation

// Navigate to another action
this.actionService = useService("action");

this.actionService.doAction({
    type: "ir.actions.act_window",
    res_model: "sale.order",
    res_id: orderId,
    views: [[false, "form"]],
});

// Open in new tab
this.actionService.doAction({
    type: "ir.actions.act_url",
    url: "/web#id=" + orderId + "&model=sale.order&view_type=form",
    target: "new",
});

Asset Registration

<!-- In __manifest__.py -->
'assets': {
    'web.assets_backend': [
        'my_module/static/src/js/dashboard.js',
        'my_module/static/src/xml/dashboard.xml',
        'my_module/static/src/css/dashboard.css',
    ],
},

Common Pitfalls

  • Tag mismatch — The tag in ir.actions.client must exactly match the string in registry.category("actions").add().
  • Missing asset bundle — JS/XML/CSS files must be listed in __manifest__.py assets. Otherwise the component is not loaded.
  • Forgetting onWillStart — Async data loading should happen in onWillStart, not in setup(). OWL renders the component only after onWillStart resolves.
  • State reactivity — Use useState() for reactive state. Direct property assignment does not trigger re-renders.