Web Customization & Snippets in Odoo 19: What's Different from Version 18?

May 29, 2026 by
Web Customization & Snippets in Odoo 19: What's Different from Version 18?
Fazri Muhammad Yazid
| No comments yet

Overview

The Odoo website builder has always been one of the most powerful yet nuanced areas to customize. With each major release, Odoo progressively refactors its frontend stack, and Odoo 19 is the most dramatic leap yet. If you are coming from Odoo 17 or 18 and are trying to create or extend a custom snippet, you will quickly discover that many patterns you relied on are either gone, deprecated, or fundamentally restructured. This blog covers the three biggest shifts in how you write, register, and customize web snippets in Odoo 19, grounded in real code from the codebase itself.


1. The Death of publicWidget Meet Interaction

This is the single biggest change you need to know about. In Odoo 17 and 18, the standard way to bring a snippet to life with JavaScript was to define a publicWidget. You would import snippets.animation, extend publicWidget.Widget, and register it into the publicWidget.registry:

// ❌ The OLD way (Odoo 17 / 18)
import publicWidget from "@web/legacy/js/public/public_widget";

publicWidget.registry.MyCustomSnippet = publicWidget.Widget.extend({
    selector: ".s_my_custom_snippet",
    disabledInEditableMode: true,

    start: function () {
        // initialize your snippet here
        this._super.apply(this, arguments);
        console.log("My snippet started!");
    },

    destroy: function () {
        // clean up here
        this._super.apply(this, arguments);
    },
});

In Odoo 19, this system is replaced by the Interaction class, imported from @web/public/interaction. The Interaction class is a purpose-built, framework-native solution that integrates deeply with the OWL service system, has a clean lifecycle, and supports a powerful declarative event binding system called dynamicContent.

// ✅ The NEW way (Odoo 19)
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";

export class MyCustomSnippet extends Interaction {
    static selector = ".s_my_custom_snippet";

    // Declarative event binding — no more manual addEventListener!
    dynamicContent = {
        ".s_my_btn": {
            "t-on-click": this.onButtonClick.bind(this),
        },
        _root: {
            "t-att-class": () => ({
                "is-active": this.isActive,
            }),
        },
    };

    setup() {
        this.isActive = false;
    }

    start() {
        console.log("My snippet started via Interaction!");
    }

    onButtonClick() {
        this.isActive = !this.isActive;
        this.updateContent(); // triggers dynamicContent re-evaluation
    }
}

// Register to the public interactions registry
registry.category("public.interactions").add("website.my_custom_snippet", MyCustomSnippet);


The key differences:

Feature

Odoo 17/18 (publicWidget)

Odoo 19 (Interaction)

Base import

@web/legacy/js/public/public_widget
@web/public/interaction

Registration

publicWidget.registry.X = Widget.extend({...})
registry.category("public.interactions").add(...)

Selector

Instance property

static selector class property

Event binding

Manual events: { "click .btn": handler }

Declarative dynamicContent object

Edit mode isolation

disabledInEditableMode: true

Handled via separate .edit.js mixin

CSS class binding

Manual DOM manipulation

Declarative t-att-class in dynamicContent


2. The Edit/Public Split Two Separate Files Per Snippet

In Odoo 17 and 18, a single publicWidget file would handle both the public-facing behavior and the edit-mode behavior. You used properties like disabledInEditableMode, edit_events, and read_events to differentiate the two modes.

In Odoo 19, this duality is gone. Odoo now enforces a strict file separation:

  • my_snippet.js — handles behavior for public visitors (production mode)
  • my_snippet.edit.js — handles additional behavior specific to the website editor

The edit.js file works on a mixin pattern: it extends your base Interaction class with editor-specific logic without polluting the public bundle. Here is how Odoo 19's built-in s_countdown snippet implements this:

countdown.js (public behavior — runs for all visitors):

/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";

export class Countdown extends Interaction {
    static selector = ".s_countdown";

    start() {
        this.setInterval = setInterval(this.render.bind(this), 1000);
    }
    // ... the full countdown logic lives here
}

registry.category("public.interactions").add("website.countdown", Countdown);


countdown.edit.js (editor behavior — only loaded in website builder):

/** @odoo-module **/
import { Countdown } from "./countdown";
import { registry } from "@web/core/registry";

// The mixin pattern: a function that returns a class extending the Interaction
const CountdownEdit = (I) =>
    class extends I {
        setup() {
            super.setup();
            // Example: refresh overlay controls after setup
            this.websiteEditService = this.services.website_edit;
            this.websiteEditService.callShared("builderOverlay", "refreshOverlays");
        }

        // Override to prevent hiding the countdown during editing
        get shouldHideCountdown() {
            return false;
        }

        // Disable countdown-end action in the editor
        handleEndCountdownAction() {}
    };

// Register to the EDIT-specific registry
registry.category("public.interactions.edit").add("website.countdown", {
    Interaction: Countdown,
    mixin: CountdownEdit,
});


For your custom snippet, this means creating two files:

static/src/snippets/s_my_snippet/
    ├── my_snippet.js         # public behavior
    └── my_snippet.edit.js    # editor-only overrides

This is cleaner and more performant. The editor-specific code is never shipped to your public visitors.


3. The Builder Plugin System Replacing .snippet-option Classes

In Odoo 17 and 18, you extended the snippet options panel (the right-hand sidebar in the website builder) by creating a JavaScript class using options.Class.extend() with the xmlDependencies and associated XML template. The option was linked to a snippet via a data-js attribute or a CSS selector in snippets/options.xml.

In Odoo 19, this system has been completely replaced by the Builder Plugin system, housed in the new html_builder module. Options are now OWL components registered as plugins via a plugin architecture, where each plugin can declare its resources (lifecycle hooks into the editor).

Here is a simplified real-world example of creating a custom option for your snippet in Odoo 19:

static/src/builder/plugins/my_snippet_option_plugin.js:

/** @odoo-module **/
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";
import { MySnippetOption } from "./my_snippet_option";

export class MySnippetOptionPlugin extends Plugin {
    static id = "mySnippetOption";

    // Declare resources: these are hooks into the builder lifecycle
    resources = {
        // This tells the Builder to render your option component
        // when the user selects an element matching the selector
        builder_options: [
            {
                OptionComponent: MySnippetOption,
                selector: ".s_my_custom_snippet",
            },
        ],
    };
}

// Register into the website-plugins registry
registry.category("website-plugins").add(
    MySnippetOptionPlugin.id,
    MySnippetOptionPlugin
);


static/src/builder/plugins/my_snippet_option.js (the OWL component):

/** @odoo-module **/
import { Component } from "@odoo/owl";
import { BuilderButton } from "@html_builder/core/builder_components";

export class MySnippetOption extends Component {
    static template = "my_module.MySnippetOption";
    static components = { BuilderButton };

    doSomething() {
        // Use the editor's action system to apply changes to the DOM
        this.env.editor.dispatch("customAction", { target: this.env.editingElement });
    }
}


static/src/builder/plugins/my_snippet_option.xml (the OWL template):

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
    <t t-name="my_module.MySnippetOption">
        <div class="o_we_option">
            <BuilderButton action="doSomething">
                My Custom Action
            </BuilderButton>
        </div>
    </t>
</templates>


Putting It All Together: Your Custom Snippet Checklist for Odoo 19

When creating a brand-new custom snippet from scratch in Odoo 19, here is the minimum project structure you need:

my_module/
└── static/
    └── src/
        ├── snippets/
        │   └── s_my_snippet/
        │       ├── my_snippet.js          # Interaction class (public behavior)
        │       └── my_snippet.edit.js     # Edit mixin (editor behavior)
        └── builder/
            └── plugins/
                ├── my_snippet_option_plugin.js   # Plugin registration
                ├── my_snippet_option.js           # OWL option component
                └── my_snippet_option.xml          # OWL template


And in your __manifest__.py, load them into the correct asset bundles:

'assets': {
    'web.assets_frontend': [
        'my_module/static/src/snippets/s_my_snippet/my_snippet.js',
    ],
    'web.assets_frontend_editor': [    # Only loaded in edit mode
        'my_module/static/src/snippets/s_my_snippet/my_snippet.edit.js',
    ],
    'website.assets_wysiwyg': [        # Builder / sidebar options
        'my_module/static/src/builder/plugins/my_snippet_option_plugin.js',
        'my_module/static/src/builder/plugins/my_snippet_option.js',
        'my_module/static/src/builder/plugins/my_snippet_option.xml',
    ],
}
Web Customization & Snippets in Odoo 19: What's Different from Version 18?
Fazri Muhammad Yazid May 29, 2026
Share this post
Archive
Sign in to leave a comment