Standalone OWL JS Project Structure
For a standalone OWL JS app built with Vite + npm (Approach 3 from the Setup lesson), the recommended structure is:
my-owl-app/
├── index.html ← HTML entry point, contains <div id="app">
├── package.json ← npm config: @odoo/owl dependency, scripts
├── vite.config.js ← Vite build config
│
├── src/
│ ├── main.js ← App entry: imports App, calls mount()
│ │
│ ├── App.js ← Root component — composes everything else
│ │
│ ├── components/ ← Reusable UI components
│ │ ├── Button.js
│ │ ├── Modal.js
│ │ └── DataTable.js
│ │
│ ├── views/ ← Page-level components (routed views)
│ │ ├── HomeView.js
│ │ ├── UserListView.js
│ │ └── SettingsView.js
│ │
│ ├── hooks/ ← Custom OWL hooks (reusable logic)
│ │ ├── useLocalStorage.js
│ │ └── useFetch.js
│ │
│ ├── services/ ← App-wide services (injectable via env)
│ │ ├── ApiService.js
│ │ └── NotificationService.js
│ │
│ └── utils/ ← Helper functions (not components)
│ ├── formatDate.js
│ └── validators.js
│
└── tests/ ← Unit tests for components and hooks
├── App.test.js
└── components/
└── Button.test.js
Key files explained
| File | Purpose |
|---|---|
index.html | The HTML shell. Only contains <div id="app"> and a <script type="module"> pointing to src/main.js |
src/main.js | Imports the root App component and calls mount(App, document.getElementById("app")). May also configure the OWL app environment here. |
src/App.js | The root component. Defines the top-level layout and registers all top-level sub-components. |
src/components/ | Reusable, stateless-or-self-contained UI pieces: buttons, inputs, modals, cards. Components here should work in any context. |
src/views/ | Page-level components associated with routes or major app screens. They compose components/ pieces together. |
src/hooks/ | Custom OWL hooks — functions that call useState, onMounted, etc. to encapsulate reusable stateful logic. |
src/services/ | Services shared across the app via OWL's environment system. Examples: API client, notification manager, auth service. |
Odoo Module Structure with OWL JS
When building a real Odoo module, your OWL JS code lives inside a Python module directory. Odoo has a specific convention for where front-end assets (JavaScript, CSS, templates) go. Here is the anatomy of a custom Odoo module named my_custom_module:
my_custom_module/
├── __init__.py ← Python: makes this a Python package
├── __manifest__.py ← Odoo: module name, version, depends, data files
│
├── models/ ← Python: Odoo ORM models (database)
│ ├── __init__.py
│ └── sale_order_custom.py
│
├── views/ ← XML: Odoo form/tree/kanban view definitions
│ └── sale_order_views.xml
│
├── security/ ← CSV/XML: access rights and record rules
│ └── ir.model.access.csv
│
└── static/ ← ALL front-end assets (served to browser)
├── description/
│ └── icon.png ← Module icon shown in Odoo App Store
│
└── src/ ← OWL JS source code
├── js/
│ ├── components/ ← OWL components
│ │ ├── MyWidget.js
│ │ └── CustomKanban.js
│ ├── views/ ← OWL view overrides (extends Odoo views)
│ │ └── sale_form_view.js
│ └── main.js ← Entry point: registers components
│
├── xml/ ← OWL templates (alternative to xml`` helper)
│ └── my_templates.xml
│
└── css/ ← Custom CSS for your components
└── my_module.css
static/ directory is special in Odoo
Odoo serves files inside static/ directly to the browser without any Python processing. This is where all your JavaScript, CSS, and image assets live. Odoo's asset bundling system (defined in __manifest__.py or ir.asset records) tells Odoo which JS and CSS files to include in the browser bundle.
The __manifest__.py File
The manifest tells Odoo about your module and declares your front-end assets:
{
"name": "My Custom Module",
"version": "17.0.1.0.0",
"category": "Sales",
"summary": "Custom OWL widgets for Sales",
"author": "Your Company",
"depends": ["sale", "web"], # Odoo modules this depends on
"license": "LGPL-3",
# Declare your JavaScript and CSS assets
"assets": {
# web.assets_backend = the main Odoo backend bundle
"web.assets_backend": [
"my_custom_module/static/src/js/components/MyWidget.js",
"my_custom_module/static/src/js/views/sale_form_view.js",
"my_custom_module/static/src/css/my_module.css",
],
},
# XML views, security files, and data
"data": [
"security/ir.model.access.csv",
"views/sale_order_views.xml",
],
"installable": True,
"application": False,
}
Registering OWL Components in Odoo
In a standalone app you call mount(App, el) to start OWL. In Odoo, you do not call mount() directly — instead you extend or patch existing Odoo views and components using Odoo's registry system:
/** @odoo-module */
// The @odoo-module comment tells Odoo's bundler this is an ES module
import { Component, xml, useState } from "@web/owl";
// In Odoo, OWL is imported from "@web/owl" (Odoo's internal alias)
// Import Odoo's registry to register your component as a field widget
import { registry } from "@web/core/registry";
// Your custom OWL component
class ColorPickerWidget extends Component {
static template = xml`
<div class="color-picker-widget">
<div t-foreach="colors" t-as="color" t-key="color"
t-att-style="'background:' + color"
t-att-class="{ selected: props.value === color }"
t-on-click="() => props.update(color)">
</div>
</div>
`;
get colors() {
return ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6"];
}
}
// Register as a field widget — Odoo will use this for fields
// where widget="color_picker" is specified in a view XML
registry.category("fields").add("color_picker", ColorPickerWidget);
"@web/owl", not "@odoo/owl"
Inside an Odoo module, OWL is available as @web/owl — Odoo provides it through its own asset system. Do not try to install @odoo/owl via npm inside an Odoo module. Odoo also provides its own useState, Component, etc. through this alias. When learning OWL standalone (this course), use @odoo/owl or the CDN. When writing real Odoo modules, use @web/owl.
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Component file | PascalCase, matches class name | UserCard.js, SalesKanban.js |
| Component class | PascalCase | class UserCard extends Component |
| Hook file | camelCase, prefixed use | useSearch.js, useNotification.js |
| Service file | camelCase + "Service" | notificationService.js |
| CSS file | snake_case (Odoo convention) | my_module.css |
| Template IDs | ModuleName.ComponentName | MyModule.UserCard |
| Odoo module dir | snake_case | my_custom_module/ |
Anatomy of a Single Component File
Here is what a well-structured OWL JS component file looks like:
// 1. OWL imports
import { Component, xml, useState, onMounted } from "@odoo/owl";
// 2. Sub-component imports (if any)
import { Avatar } from "./Avatar.js";
import { Badge } from "./Badge.js";
// 3. Component class
export class UserCard extends Component {
// 4. Static props declaration — documents and validates incoming props
static props = {
userId: Number,
name: String,
role: { type: String, optional: true },
onDelete: { type: Function, optional: true },
};
// 5. Default props (optional)
static defaultProps = {
role: "Member",
};
// 6. Sub-component registry
static components = { Avatar, Badge };
// 7. Reactive state
state = useState({ expanded: false });
// 8. Lifecycle hooks — called in setup()
setup() {
onMounted(() => {
console.log(`UserCard for user ${this.props.userId} mounted`);
});
}
// 9. Computed getters
get initials() {
return this.props.name
.split(" ")
.map(n => n[0])
.join("")
.toUpperCase();
}
// 10. Event handlers
toggleExpanded() {
this.state.expanded = !this.state.expanded;
}
handleDelete() {
if (this.props.onDelete) {
this.props.onDelete(this.props.userId);
}
}
// 11. Template — defined last so methods are visible above it
static template = xml`
<div class="user-card" t-att-class="{ expanded: state.expanded }">
<Avatar initials="initials"/>
<div class="user-info">
<h3 t-esc="props.name"/>
<Badge label="props.role"/>
</div>
<button t-on-click="toggleExpanded">
<t t-if="state.expanded">▲ Less</t>
<t t-else="">▼ More</t>
</button>
<div t-if="state.expanded" class="user-details">
<p>ID: <t t-esc="props.userId"/></p>
<button t-if="props.onDelete" t-on-click="handleDelete">Delete</button>
</div>
</div>
`;
}
📋 Summary
- Standalone OWL projects: organise into
components/,views/,hooks/,services/,utils/. - Odoo modules: all front-end assets live in
static/src/. Assets are declared in__manifest__.pyunder theassetskey. - In Odoo, OWL components are registered in Odoo's registry system (not mounted directly).
- In Odoo modules, import OWL from
"@web/owl"— NOT"@odoo/owl". - Naming: PascalCase for component files and classes; camelCase for hooks and utilities; snake_case for Odoo module directories.
- A well-structured component file: imports → class → static props → static components → state → setup() → getters → event handlers → template.
Frequently Asked Questions
Yes — you can export multiple classes from one file. For small, tightly related components (like a parent and a private child it exclusively uses), a single file is fine. For components that are reused in many places, give each one its own file. The general rule: one public component per file, with small private helpers in the same file if they are not reused elsewhere.
The /** @odoo-module */ comment at the top of a JavaScript file tells Odoo's asset bundler to treat the file as an ES module with import/export syntax. Without it, Odoo assumes the file is a legacy script. In modern Odoo 16/17 development you should always include it at the top of every JavaScript file in your module.
In Odoo, you can define OWL templates in separate .xml files inside static/src/xml/. These XML files use the same t-* directive syntax as inline xml`` templates. You reference them by template ID (e.g. MyModule.MyComponent). Separate XML templates are useful for very long templates that would clutter your JS file. In standalone OWL apps, the inline xml`` helper is simpler and is the modern preferred approach.