Odoo Theme Development Guide: Build Custom Themes for Odoo 17/18
The Odoo theme ecosystem generates over $12 million annually on the Odoo App Store, yet fewer than 8% of themes receive a 5-star rating. The difference between a forgettable theme and a best-seller comes down to understanding Odoo's rendering pipeline, QWeb template inheritance, and the website builder's drag-and-drop architecture.
This guide covers every layer of Odoo theme development — from project scaffolding to advanced SCSS architecture, from building custom snippets to making your theme work seamlessly with Odoo's website builder.
Key Takeaways
- Odoo themes are standard modules with a specific directory structure — they extend the
websitemodule and register templates, SCSS, and JavaScript via the asset bundling system. - QWeb is Odoo's server-side template engine — it uses XML directives (t-if, t-foreach, t-call, t-inherit) that compile to HTML. Understanding template inheritance with XPath is essential for customizing any part of the website.
- The SCSS pipeline compiles through Odoo's asset manager — custom variables override Bootstrap 5 defaults, and
_variables.scssmust be loaded before the framework. - Website builder integration requires defining snippet options, custom building blocks, and dynamic content blocks that non-technical users can configure through the drag-and-drop editor.
- Responsive design in Odoo leverages Bootstrap 5's grid system with Odoo-specific utilities — test across all breakpoints and consider RTL languages for international deployments.
1. Theme Module Structure
An Odoo theme follows the standard module structure with website-specific additions:
theme_ecosire/
├── __init__.py
├── __manifest__.py
├── data/
│ ├── theme_data.xml # Default pages, menus
│ └── images.xml # Pre-loaded image library
├── static/
│ ├── description/
│ │ ├── icon.png # Module icon (128x128)
│ │ └── icon.svg # SVG version
│ ├── src/
│ │ ├── img/ # Theme images
│ │ ├── fonts/ # Custom fonts
│ │ ├── scss/
│ │ │ ├── _variables.scss # Bootstrap variable overrides
│ │ │ ├── _mixins.scss # Custom SCSS mixins
│ │ │ ├── _typography.scss # Font stacks, sizes
│ │ │ ├── _header.scss # Header styles
│ │ │ ├── _footer.scss # Footer styles
│ │ │ ├── _snippets.scss # Snippet-specific styles
│ │ │ └── primary.scss # Main entry point
│ │ ├── js/
│ │ │ ├── snippets/ # Snippet JS (animations, interactions)
│ │ │ └── theme.js # Theme-wide JS
│ │ └── xml/
│ │ └── snippets.xml # OWL snippet templates
│ └── img/ # Public images (backgrounds, icons)
├── views/
│ ├── assets.xml # Asset bundle registration
│ ├── layout.xml # Header/footer templates
│ ├── pages.xml # Pre-built page templates
│ └── snippets/
│ ├── options.xml # Snippet options (color pickers, etc.)
│ └── s_custom_block.xml # Custom snippet definitions
└── tests/
└── test_theme.py # Theme installation tests
The Theme Manifest
# __manifest__.py
{
'name': 'Theme Ecosire',
'description': 'Modern, performance-optimized theme for Odoo Website',
'category': 'Theme',
'version': '18.0.1.0.0',
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'license': 'LGPL-3',
'depends': ['website'],
'data': [
'views/assets.xml',
'views/layout.xml',
'views/pages.xml',
'views/snippets/options.xml',
'views/snippets/s_custom_block.xml',
],
'assets': {
'web.assets_frontend': [
# Variables MUST come before Bootstrap
('prepend', 'theme_ecosire/static/src/scss/_variables.scss'),
'theme_ecosire/static/src/scss/primary.scss',
'theme_ecosire/static/src/js/theme.js',
],
'website.assets_wysiwyg': [
'theme_ecosire/static/src/js/snippets/*.js',
],
},
'images': [
'static/description/banner.png',
'static/description/theme_screenshot.jpg',
],
'snippet_lists': {
'website.snippets': [
('replace', 'theme_ecosire.s_custom_hero'),
],
},
'installable': True,
'application': False,
'auto_install': False,
}
Critical note about the assets key: In Odoo 17+, the assets dictionary in __manifest__.py is the preferred way to register static files. The older views/assets.xml approach using <template inherit_id="web.assets_frontend"> still works but is being phased out. Use both for maximum compatibility.
2. QWeb Templating Deep Dive
QWeb is Odoo's XML-based template engine. It processes directives prefixed with t- and outputs HTML. Understanding QWeb is non-negotiable for theme development.
Core Directives
<!-- Conditional rendering -->
<div t-if="website.is_publisher()">
<span>Edit mode controls here</span>
</div>
<div t-elif="request.env.user._is_internal()">
<span>Internal user view</span>
</div>
<div t-else="">
<span>Public visitor view</span>
</div>
<!-- Loops -->
<ul>
<t t-foreach="website.menu_id.child_id" t-as="menu_item">
<li t-attf-class="nav-item #{('active' if menu_item.is_active else '')}">
<a t-att-href="menu_item.url" t-out="menu_item.name"/>
</li>
</t>
</ul>
<!-- Variable setting -->
<t t-set="company" t-value="res_company"/>
<t t-set="primary_color" t-value="'#FF6B00'"/>
<!-- Output (auto-escaped) -->
<span t-out="partner.name"/>
<!-- Raw HTML output (use with caution) -->
<div t-raw="sanitized_html_content"/>
<!-- Dynamic attributes -->
<div t-att-id="'section-' + str(section.id)"
t-attf-class="container #{extra_class}"
t-att-data-section="section.sequence"/>
<!-- Template calling -->
<t t-call="theme_ecosire.hero_section">
<t t-set="title">Welcome to Our Store</t>
<t t-set="subtitle">Premium Odoo Themes</t>
</t>
Template Inheritance with XPath
This is where theme development gets powerful. You can modify any existing template without replacing it:
<!-- views/layout.xml -->
<template id="custom_header" inherit_id="website.layout" name="Custom Header">
<!-- Replace the entire header -->
<xpath expr="//header" position="replace">
<header id="top" class="ecosire-header">
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" t-att-href="'/'">
<img t-if="website.logo"
t-att-src="website.image_url(website, 'logo')"
alt="Logo" class="img-fluid" style="max-height: 50px;"/>
</a>
<!-- Custom mega menu -->
<t t-call="theme_ecosire.mega_menu"/>
<!-- CTA button -->
<a href="/contactus" class="btn btn-primary ms-3">
Get Started
</a>
</div>
</nav>
</header>
</xpath>
<!-- Add a top bar before the header -->
<xpath expr="//header[@id='top']" position="before">
<div class="ecosire-topbar bg-dark text-white py-1">
<div class="container d-flex justify-content-between">
<span t-out="res_company.phone"/>
<span t-out="res_company.email"/>
</div>
</div>
</xpath>
<!-- Modify footer: add a newsletter section -->
<xpath expr="//footer" position="inside">
<div class="ecosire-newsletter bg-primary text-white py-5">
<div class="container text-center">
<h3>Stay Updated</h3>
<t t-call="website.s_newsletter_block"/>
</div>
</div>
</xpath>
</template>
XPath Position Reference
| Position | Effect |
|---|---|
replace | Replace the matched element entirely |
inside | Append inside the matched element |
before | Insert before the matched element |
after | Insert after the matched element |
attributes | Modify attributes of the matched element |
<!-- Modify attributes example -->
<xpath expr="//div[@id='wrapwrap']" position="attributes">
<attribute name="class" add="ecosire-theme" separator=" "/>
</xpath>
<!-- Remove an element -->
<xpath expr="//div[hasclass('o_not_editable')]" position="replace"/>
3. SCSS Architecture
Odoo's SCSS pipeline compiles through its asset manager, which means your variable overrides must be loaded before Bootstrap's defaults. Use the 'prepend' directive in your manifest's assets dictionary to ensure your _variables.scss file takes priority. This single file controls colors, fonts, spacing, border radius, and every other Bootstrap design token in your theme.
Variable Overrides (_variables.scss)
// _variables.scss — loaded BEFORE Bootstrap
// Override Bootstrap 5 defaults
// Brand Colors
$o-brand-primary: #FF6B00;
$o-brand-secondary: #1A1F36;
// Bootstrap color overrides
$primary: $o-brand-primary;
$secondary: $o-brand-secondary;
$success: #00C853;
$info: #2196F3;
$warning: #FFB300;
$danger: #FF1744;
// Typography
$font-family-sans-serif: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-family-monospace: 'JetBrains Mono', 'Fira Code', monospace;
$font-size-base: 1rem;
$headings-font-weight: 700;
$headings-line-height: 1.2;
// Spacing
$spacer: 1rem;
// Border radius
$border-radius: 0.5rem;
$border-radius-lg: 1rem;
$border-radius-pill: 50rem;
// Shadows
$box-shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
$box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
$box-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
// Navbar
$navbar-padding-y: 1rem;
$navbar-brand-font-size: 1.5rem;
// Cards
$card-border-radius: $border-radius-lg;
$card-border-color: transparent;
$card-box-shadow: $box-shadow;
Main Stylesheet (primary.scss)
// primary.scss — Main entry point
@import 'variables'; // Already prepended, but import for IDE support
@import 'mixins';
@import 'typography';
@import 'header';
@import 'footer';
@import 'snippets';
// ============================================
// Global Overrides
// ============================================
#wrapwrap.ecosire-theme {
// Smooth scrolling
scroll-behavior: smooth;
// Better text rendering
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// ============================================
// Section Spacing
// ============================================
.s_section {
padding: 5rem 0;
@media (max-width: 768px) {
padding: 3rem 0;
}
}
// ============================================
// Button Styles
// ============================================
.btn-primary {
background: linear-gradient(135deg, $primary, darken($primary, 10%));
border: none;
padding: 0.75rem 2rem;
font-weight: 600;
letter-spacing: 0.02em;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba($primary, 0.4);
}
}
// ============================================
// Card Component
// ============================================
.ecosire-card {
background: white;
border-radius: $border-radius-lg;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: $box-shadow-lg;
}
&__image {
aspect-ratio: 16 / 9;
object-fit: cover;
width: 100%;
}
&__body {
padding: 1.5rem;
}
}
Custom Font Loading
// _typography.scss
@font-face {
font-family: 'Inter';
src: url('/theme_ecosire/static/src/fonts/Inter-Variable.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
font-style: normal;
}
@font-face {
font-family: 'Inter';
src: url('/theme_ecosire/static/src/fonts/Inter-Variable-Italic.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
font-style: italic;
}
// Heading hierarchy
h1, .h1 { font-size: 2.5rem; font-weight: 800; }
h2, .h2 { font-size: 2rem; font-weight: 700; }
h3, .h3 { font-size: 1.5rem; font-weight: 600; }
h4, .h4 { font-size: 1.25rem; font-weight: 600; }
4. Custom Snippets (Building Blocks)
Snippets are the drag-and-drop building blocks in Odoo's website builder. Creating custom snippets is what makes a theme truly valuable.
Defining a Snippet Template
<!-- views/snippets/s_custom_block.xml -->
<odoo>
<!-- The snippet template -->
<template id="s_ecosire_hero" name="Ecosire Hero Section">
<section class="s_ecosire_hero pt-5 pb-5 o_cc o_cc1">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-6">
<h1 class="display-4 fw-bold">
Transform Your Business
</h1>
<p class="lead text-muted mt-3">
Enterprise solutions that scale with your ambitions.
</p>
<div class="mt-4">
<a href="/contactus" class="btn btn-primary btn-lg me-3">
Get Started
</a>
<a href="/about" class="btn btn-outline-secondary btn-lg">
Learn More
</a>
</div>
</div>
<div class="col-lg-6">
<img src="/theme_ecosire/static/src/img/hero-illustration.svg"
alt="Hero" class="img-fluid" loading="lazy"/>
</div>
</div>
</div>
</section>
</template>
<!-- Register snippet in the builder panel -->
<template id="snippets" inherit_id="website.snippets">
<xpath expr="//div[@id='snippet_structure']//t[@t-snippet]/.."
position="inside">
<t t-snippet="theme_ecosire.s_ecosire_hero"
t-thumbnail="/theme_ecosire/static/src/img/snippets/hero_thumb.png"/>
</xpath>
</template>
</odoo>
Snippet Options (User Customization)
<!-- views/snippets/options.xml -->
<odoo>
<template id="s_ecosire_hero_options" inherit_id="website.snippet_options">
<xpath expr="." position="inside">
<div data-js="EcosireHeroOptions"
data-selector=".s_ecosire_hero">
<!-- Layout selector -->
<we-select string="Layout">
<we-button data-select-class="s_hero_left">Text Left</we-button>
<we-button data-select-class="s_hero_center">Centered</we-button>
<we-button data-select-class="s_hero_right">Text Right</we-button>
</we-select>
<!-- Background style -->
<we-select string="Background">
<we-button data-select-class="bg-white">White</we-button>
<we-button data-select-class="bg-dark text-white">Dark</we-button>
<we-button data-select-class="bg-gradient">Gradient</we-button>
</we-select>
<!-- Animation toggle -->
<we-checkbox string="Enable Animation"
data-toggle-class="s_hero_animated"/>
</div>
</xpath>
</template>
</odoo>
Snippet JavaScript (Animations)
// static/src/js/snippets/s_ecosire_hero.js
/** @odoo-module */
import publicWidget from '@web/legacy/js/public/public_widget';
const EcosireHero = publicWidget.Widget.extend({
selector: '.s_ecosire_hero.s_hero_animated',
disabledInEditableMode: true,
start() {
this._super(...arguments);
this._initAnimation();
return Promise.resolve();
},
_initAnimation() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
this.el.querySelectorAll('.animate-target').forEach(el => {
observer.observe(el);
});
},
destroy() {
this._super(...arguments);
},
});
publicWidget.registry.EcosireHero = EcosireHero;
export default EcosireHero;
5. Responsive Design Best Practices
Mobile-First SCSS
// _responsive.scss
// Mobile-first breakpoints (Bootstrap 5)
// xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px
.ecosire-header {
// Mobile default
padding: 0.5rem 0;
.navbar-brand img {
max-height: 35px;
}
.nav-link {
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
// Tablet and up
@include media-breakpoint-up(md) {
padding: 0.75rem 0;
.nav-link {
padding: 0.5rem 1rem;
border-bottom: none;
}
}
// Desktop
@include media-breakpoint-up(lg) {
padding: 1rem 0;
.navbar-brand img {
max-height: 50px;
}
}
}
// Touch-friendly elements
@media (hover: none) {
.btn {
min-height: 44px;
min-width: 44px;
}
.nav-link {
min-height: 44px;
display: flex;
align-items: center;
}
}
RTL Support
// _rtl.scss — Critical for Arabic, Hebrew, Urdu markets
html[dir="rtl"] {
.ecosire-header {
.navbar-brand {
margin-left: 1rem;
margin-right: 0;
}
.btn + .btn {
margin-right: 0.75rem;
margin-left: 0;
}
}
.ecosire-card__body {
text-align: right;
}
// Use logical properties where possible
.ms-3 { margin-inline-start: 1rem; }
.me-3 { margin-inline-end: 1rem; }
}
6. Performance Optimization
Image Optimization
<!-- Use responsive images with srcset -->
<img t-att-src="'/web/image/ir.attachment/%s/datas/800x400' % image.id"
t-att-srcset="'/web/image/ir.attachment/%s/datas/400x200 400w, /web/image/ir.attachment/%s/datas/800x400 800w, /web/image/ir.attachment/%s/datas/1200x600 1200w' % (image.id, image.id, image.id)"
sizes="(max-width: 576px) 400px, (max-width: 992px) 800px, 1200px"
loading="lazy"
decoding="async"
alt="Descriptive alt text"/>
Critical CSS Inlining
<!-- views/layout.xml -->
<template id="critical_css" inherit_id="website.layout">
<xpath expr="//head" position="inside">
<style type="text/css">
/* Inline critical above-the-fold CSS */
.ecosire-header { min-height: 72px; }
.s_ecosire_hero { min-height: 60vh; }
.navbar-brand img { max-height: 50px; }
</style>
</xpath>
</template>
Asset Loading Strategy
# __manifest__.py
'assets': {
# Critical CSS — loaded in head
'web.assets_frontend': [
('prepend', 'theme_ecosire/static/src/scss/_variables.scss'),
'theme_ecosire/static/src/scss/primary.scss',
],
# Non-critical JS — deferred
'web.assets_frontend_lazy': [
'theme_ecosire/static/src/js/animations.js',
'theme_ecosire/static/src/js/parallax.js',
],
# Editor-only assets
'website.assets_wysiwyg': [
'theme_ecosire/static/src/js/snippets/*.js',
'theme_ecosire/static/src/scss/_editor.scss',
],
},
7. Website Builder Integration
Dynamic Content Blocks
<!-- A snippet that fetches products dynamically -->
<template id="s_featured_products" name="Featured Products Grid">
<section class="s_featured_products pt-5 pb-5">
<div class="container">
<h2 class="text-center mb-5">Featured Products</h2>
<div class="row" t-if="products">
<t t-foreach="products[:8]" t-as="product">
<div class="col-md-3 mb-4">
<div class="ecosire-card h-100">
<img t-att-src="product.image_url"
t-att-alt="product.name"
class="ecosire-card__image" loading="lazy"/>
<div class="ecosire-card__body">
<h5 t-out="product.name"/>
<p class="text-primary fw-bold"
t-out="product.list_price"
t-options='{"widget": "monetary"}'/>
<a t-att-href="product.website_url"
class="btn btn-outline-primary btn-sm">
View Details
</a>
</div>
</div>
</div>
</t>
</div>
</div>
</section>
</template>
Page Templates
<!-- Pre-built page templates that users can select -->
<template id="landing_page_template" name="Landing Page">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<t t-call="theme_ecosire.s_ecosire_hero"/>
<t t-call="theme_ecosire.s_features_grid"/>
<t t-call="theme_ecosire.s_testimonials"/>
<t t-call="theme_ecosire.s_cta_banner"/>
</div>
</t>
</template>
8. Testing Your Theme
# tests/test_theme.py
from odoo.tests.common import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestThemeEcosire(HttpCase):
def test_01_theme_installation(self):
"""Theme installs without errors."""
module = self.env['ir.module.module'].search([
('name', '=', 'theme_ecosire')
])
self.assertEqual(module.state, 'installed')
def test_02_homepage_loads(self):
"""Homepage renders with theme applied."""
response = self.url_open('/')
self.assertEqual(response.status_code, 200)
self.assertIn('ecosire-theme', response.text)
def test_03_snippets_available(self):
"""Custom snippets appear in the builder."""
self.start_tour('/web#action=website.action_website',
'theme_ecosire_snippet_tour', login='admin')
def test_04_responsive_meta(self):
"""Viewport meta tag is present."""
response = self.url_open('/')
self.assertIn('viewport', response.text)
9. Publishing on the Odoo App Store
| Requirement | Details |
|---|---|
| Screenshots | 3-5 high-quality screenshots (1920x1080) |
| Icon | 128x128 PNG in static/description/ |
| Description | HTML description in static/description/index.html |
| License | LGPL-3 for themes (standard practice) |
| Version | Match Odoo series (18.0.x.y.z) |
| Testing | Pass Odoo's automated linting checks |
| Demo data | Include demo data for try-before-buy |
Frequently Asked Questions
Can I use Tailwind CSS in an Odoo theme?
While technically possible, using Tailwind CSS alongside Odoo's built-in Bootstrap 5 creates conflicts and increases bundle size significantly. The recommended approach is to use Bootstrap 5 utilities and SCSS custom properties, which integrate natively with Odoo's asset pipeline and website builder. If you need utility classes that Bootstrap does not provide, create custom SCSS utilities instead.
How do I make my theme compatible with both Odoo 17 and 18?
Create separate branches for each Odoo version. Odoo 17 uses Bootstrap 5.1 while Odoo 18 uses Bootstrap 5.3 with CSS variable support. The asset registration also changed — Odoo 18 prefers the manifest assets key while Odoo 17 uses XML template inheritance. Maintain shared SCSS partials and version-specific integration files.
Why are my SCSS variable overrides not working?
The most common cause is load order. Your _variables.scss must be loaded before Bootstrap compiles. Use the 'prepend' directive in your manifest: ('prepend', 'theme_name/static/src/scss/_variables.scss'). Also ensure you are overriding the correct variable names — Odoo wraps some Bootstrap variables with o- prefixed versions like $o-brand-primary.
How do custom snippets work with the website builder?
Custom snippets are QWeb templates registered in the snippet panel through template inheritance of website.snippets. Each snippet needs a template for its HTML structure, optional options defined in website.snippet_options for the customization panel, and optional JavaScript extending publicWidget.Widget for interactivity. The website builder automatically handles drag-and-drop, inline editing, and undo/redo for registered snippets.
What is the best way to add custom fonts to an Odoo theme?
Place WOFF2 font files in static/src/fonts/ and define @font-face rules in your SCSS. Use font-display: swap for performance. Then override the $font-family-sans-serif Bootstrap variable in your _variables.scss. Avoid loading fonts from external CDNs as it adds a DNS lookup and may fail in restricted networks. Subset fonts to only include needed character ranges.
How do I support dark mode in my Odoo theme?
Odoo 18 introduced native dark mode support through CSS custom properties. Override the dark mode variables in your theme using the [data-bs-theme="dark"] selector. Define both light and dark variants of your custom colors, backgrounds, and shadows. The website builder toggle handles the switching — your theme just needs to provide the right CSS variable values for both modes.
Next Steps
Theme development is where design meets engineering. A well-built Odoo theme can serve hundreds of customers and generate significant recurring revenue on the Odoo App Store.
For related guides:
- Odoo Python Development Guide — Backend development fundamentals
- Odoo Custom Module Development — Module architecture overview
- Odoo REST API Tutorial — API integration patterns
Need a custom Odoo theme or website redesign? ECOSIRE's Odoo customization team designs and builds enterprise-grade themes with full website builder integration, responsive design across all breakpoints, and RTL support for international deployments. We have delivered custom themes for clients in 40+ countries. Contact us for a free design consultation.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Related Articles
AI-Powered Customer Segmentation: From RFM to Predictive Clustering
Learn how AI transforms customer segmentation from static RFM analysis to dynamic predictive clustering. Implementation guide with Python, Odoo, and real ROI data.
AI for Supply Chain Optimization: Visibility, Prediction & Automation
Transform supply chain operations with AI: demand sensing, supplier risk scoring, route optimization, warehouse automation, and disruption prediction. 2026 guide.
B2B E-commerce Strategy: Build a Wholesale Online Business in 2026
Master B2B e-commerce with strategies for wholesale pricing, account management, credit terms, punchout catalogs, and Odoo B2B portal configuration.