Skip to Content

Odoo Website SEO: Lessons From a Bilingual Odoo 19 Site


Odoo website SEO on a bilingual Odoo 19 site: nine traps we met, from term-by-term Html translations to detached theme views, each with its cause in Odoo's core and its fix.


The examples come from https://www.ogma-events.com/. We built the site on Odoo 19, with custom modules.


English is the default language, without prefix; French is served under /fr.


The goal is not to report rankings. The goal is to show where Odoo's defaults and a bilingual site disagree, and how to fix it. None of these problems is about titles or meta descriptions: they sit in the ORM, the routing and the theme views, which is why Odoo SEO on a multilingual site is also development work. File paths refer to the Odoo 19 core.


HTML fields are translated term by term


Each French page carries an hreflang annotation that says it is the French version of an English URL. If that French page displays English text, the annotation is wrong and both URLs carry the same content.


Speaker biographies and theme descriptions had been stored in French as the en_US source value, with no French translation. A bulk load wrote the English text in the en_US context, as for Char fields. Minutes later, every Html field on the French pages showed English. Writing the French back from a fr_FR context does not help: it erases the English.


The cause is in odoo/orm/fields_textual.py. When sanitize is on, which is the default, Html._get_attrs silently replaces translate=True with html_translate. The effect is easy to reproduce in an odoo shell:


# Core, odoo/orm/fields_textual.py, Html._get_attrs()

elif attrs.get('translate') is True and attrs.get('sanitize', True):

    attrs['translate'] = html_translate


# odoo shell, on a record whose French text is stored as the en_US source

theme = env['ogma.themes'].browse(THEME_ID)   # description = fields.Html(translate=True)

theme.with_context(lang='en_US').write({'description': '<p>English text.</p>'})

theme.with_context(lang='fr_FR').description  # '<p>English text.</p>': the French is gone



The field is translated term by term, roughly one term per block of text. On write, BaseString.write rebuilds the other languages: it maps each old term to its translations and pairs it with a new term through get_close_matches, with a 0.9 cutoff. An English sentence never looks 90% like its French original, so fr_FR receives the English text as is. One exception: with sanitize=False, an Html(translate=True) field stays translated as one block, like a Char.


Char and Text fields with translate=True are a plain JSONB merge: only the current language's key is replaced, so the French survives if a fr_FR key already exists. Here it did, because a French-speaking user had created the records (convert_to_column_insert stores the value under en_US and the user's language). That is why the Char fields held; it is not a general guarantee.


For an Html field, never write the other language's value directly. Set the English source, then map each English term to its French term with update_field_translations. To get both term lists in the same order, let Odoo split both versions:


def set_en_keep_fr(rec, fname, en_html, fr_html):

    def src_terms():

        rows, _ctx = rec.get_field_translations(fname, langs=['fr_FR'])

        return [r['source'] for r in rows]

    rec_en = rec.with_context(lang='en_US')

    rec_en.write({fname: fr_html})       # 1. source = French -> FR terms, in order

    fr_terms = src_terms()

    rec_en.write({fname: en_html})       # 2. source = English -> EN terms, in order

    en_terms = src_terms()

    if len(en_terms) != len(fr_terms):   # HTML structures differ: restore, fix by hand

        rec_en.write({fname: fr_html})

        raise ValueError(f'{rec}.{fname}: {len(en_terms)} EN vs {len(fr_terms)} FR terms')

    # 3. map each English term to its French term

    rec.update_field_translations(fname, {'fr_FR': dict(zip(en_terms, fr_terms))})



  • Step 1 lets Odoo cut the French text into its own terms.
  • Step 2 reproduces the incident on purpose: for a moment the French value is English.
  • Step 3 restores the French, term by term.


Pairing by position assumes the same HTML structure, hence the length check; our script also compares the tag sequences in a dry run first. On the production site, this procedure set 1,007 Html fields with no misalignment. https://www.ogma-events.com/speakers/11-jean-philippe-ackermann opens with "A keynote speaker and former executive…" and the French one with "Conférencier et ancien dirigeant…". Both come from the same field.


Theme view copies stop receiving module updates


A template fix was committed, deployed and applied with -u, yet the live theme page still showed the old H1. No error, no warning.


A theme module stores its templates as theme.ir.ui.view records. Odoo copies each one into an ir.ui.view per website, and the copy is what gets rendered. On -u, _update_records in website/models/ir_module_module.py drops the new arch as soon as the copy has arch_updated set. And ir.ui.view.write sets arch_updated=True on any arch write that does not specify the flag, which is what the website builder does on the smallest edit. The copy is then detached for good.


After each deploy, check the real page, not the theme view. To re-attach the detached copies in an odoo shell:


views = env['ir.ui.view'].with_context(active_test=False).search([

    ('theme_template_id', '!=', False), ('arch_updated', '=', True)])

for view in views:

    view.write({

        'arch': view.theme_template_id.arch,

        'arch_updated': False,   # same write, or ir.ui.view.write() sets it back to True

    })

env.cr.commit()   # odoo shell rolls back on exit otherwise



Diff each copy against theme_template_id.arch first: a real customisation made in the builder would be lost. For a single view, view.reset_arch(mode='hard') does the same job.


The sitemap lists the default language only


The French pages were missing from /sitemap.xml, and after a deploy it kept the old state for hours.


In website/controllers/main.py, the sitemap route is multilang=False. It calls website._enumerate_pages(), which runs every sitemap= callable in the default language, and the sitemap_locs template only emits loc elements. The result is cached in an ir.attachment for SITEMAP_CACHE_TIME, 12 hours.


The fix is an override of _enumerate_pages. Outside the editor's link search (no query_string, no force), it yields each URL, then its variant for each secondary language, with the right route and the translated slug:


class Website(models.Model):

    _inherit = 'website'


    def _enumerate_pages(self, query_string=None, force=False):

        pages = super()._enumerate_pages(query_string=query_string, force=force)

        if query_string or force:        # link search in the editor: unchanged

            yield from pages

            return

        alt_langs = self.language_ids - self.default_lang_id

        for record in pages:

            yield record

            for lang in alt_langs:

                loc = record['loc']

                if lang.code.startswith('fr') and loc.startswith('/speakers'):

                    loc = '/intervenants' + loc[len('/speakers'):]

                # our helper: swaps /themes/<id>-<slug> for the translated slug

                loc = self.env['ogma.themes'].sudo().localize_url(loc, lang.code)

                yield dict(record, loc=f'/{lang.url_code}{loc if loc != "/" else ""}')


# after a deploy: drop the cached sitemap

env['ir.attachment'].sudo().search([

    ('type', '=', 'binary'), ('url', '=like', '/sitemap-%')]).unlink()


Put the purge in a migration script: the 12 hours run from the creation of the attachment, not from the deploy.


hreflang and canonical keep the current route


Odoo gives you the language prefix and translatable fields. It does not handle a route pattern per language, nor a slug passed as a plain string. Out of the box, the hreflang alternates, the canonical tag and the language switcher pointed to /fr/speakers/... or to a French theme URL with the English slug, two forms that answer 301.


The cause is _url_localized in http_routing/models/ir_http.py. It matches the current path again, switches only the record arguments to the target language and rebuilds the URL with the same route rule: the pattern never changes, and a slug carried by a string converter is not translated.


The fix is an override that calls super() and rewrites the result for the target language, as on https://www.ogma-events.com/speakers/88-elodie-gentina, which declares its French alternate at /fr/intervenants/88-elodie-gentina, and on https://www.ogma-events.com/themes/1862-artificial-intelligence, which announces /fr/themes/1862-intelligence-artificielle.


class IrHttp(models.AbstractModel):

    _inherit = 'ir.http'


    @classmethod

    def _url_localized(cls, url=None, lang_code=None, canonical_domain=None,

                       prefetch_langs=False, force_default_lang=False):

        res = super()._url_localized(url=url, lang_code=lang_code,

            canonical_domain=canonical_domain, prefetch_langs=prefetch_langs,

            force_default_lang=force_default_lang)

        lang = request.env['res.lang']._get_data(code=lang_code) if lang_code else request.lang

        code = (lang and lang.code) or ''

        # our helpers: /speakers <-> /fr/intervenants, then the translated theme slug

        # (the core does not translate a <string:slug>)

        res = cls._ogma_localize_speakers(res, code, force_default_lang)

        return request.env['ogma.themes'].sudo().localize_url(res, code) if code else res



force_default_lang is set by the language switcher: the core then keeps an /en prefix, whose redirect resets the language cookie, and the speaker rewrite must not drop it. The sitemap uses the same helpers, so both sources agree.


Since the record is resolved from the leading id, any slug variant also answered 200 with a self-referencing canonical. The controller now answers 301 to the canonical slug, restoring the language prefix with ir.http._url_lang, except when that slug is only the id: the redirect would loop.


Thin pages: noindex through website_indexed


Hundreds of generated theme pages were an empty template or a bare list of names, yet indexable and in the sitemap.


The core layout already reads a flag for this: website.layout computes no_index from main_object.website_indexed and emits a robots noindex meta tag. Any model that exposes a website_indexed field and is rendered as main_object benefits. Our rule, as a stored computed Boolean: at least one published speaker and 200 characters of editorial text (the meta description does not count), or a manual override. The sitemap callable filters on the same field.


The trap is the deployment. A stored computed field is only initialised when its column is created: changing its rule does not recompute existing rows on -u.


website_indexed = fields.Boolean(compute='_compute_seo_indexable', store=True)  # read by website.layout


# migrations/<version>/post-migrate.py

def migrate(cr, version):

    env = api.Environment(cr, SUPERUSER_ID, {})

    Theme = env['ogma.themes']

    env.add_to_compute(Theme._fields['website_indexed'], Theme.search([]))

    env.flush_all()


Do not call the compute method directly: outside the compute engine, each assignment becomes a write() that sets write_date on the whole table, and every lastmod in the sitemap becomes identical. And a migration script only runs if installed version < script version <= manifest version; numbered lower, it is skipped without a message.


Structured data that matches the page


The JSON-LD (Person, WebPage, FAQPage) is built in Python, so nothing in Odoo ties it to the canonical computed by _url_localized. Four rules keep it consistent with the page:


- URLs: the url and @id of the JSON-LD carry the page language, like the canonical tag.

- FAQPage: only when the FAQ is displayed on the page, in the page language.

- sameAs: real host names only, without duplicates. res.partner._clean_website prefixes http:// to any text without a scheme, so a placeholder typed in the website field becomes a URL that resolves nowhere.

- Escaping: json.dumps does not escape <, > and &, so a biography could close the script tag. Replace them with their \u003c, \u003e and \u0026 escapes before rendering with t-out.


Three shorter traps


1. WebP needs an explicit Pillow import


odoo/tools/image.py calls Image.preinit() and sets Image._initialized = 2, so inside Odoo, Pillow only knows BMP, GIF, JPEG, PPM and PNG, and saving as WEBP raises KeyError('WEBP'). Import PIL.WebPImagePlugin explicitly where you encode, and do not let a broad except hide the error: the thumbnail would stay empty without a message.


2. The frontend_lang cookie redirects audit crawlers


For a URL without prefix, Odoo reads the frontend_lang cookie, which every anonymous response sets, and redirects to /fr with a 303 when it names French. User agents containing "bot", "crawl", "spider" or "curl" are exempt, so Googlebot is not affected. Audit tools with a browser User-Agent that keep cookies are: after one French page, every English URL redirects. Crawl with DefaultCookiePolicy(allowed_domains=[]) on the requests session.


3. New template methods need a worker restart


An upgrade with -u writes the new template to the database at once, but running workers do not re-import Python code: load_openerp_module in odoo/modules/module.py returns early when the module is already in sys.modules. On our test instance, a profile page answered 500: "'res.partner' object has no attribute 'ogma_langues_label'". The order is -u, restart all workers, then check a real page.


An Odoo website SEO checklist for each deploy


- Restart the workers when templates call new Python code.

- Check the served pages in both languages, not the theme views or the database.

- Review theme view copies with arch_updated set.

- Purge the cached sitemap.

- Recompute stored fields whose rule changed, in a migration script.

- Check the content type of the served images.

- Crawl without cookies.


Conclusion


None of these traps is specific to a speakers bureau: any Odoo website with a second language, a theme module or content loaded by script meets some of them. Most fail without a message: the database or the theme view looks right, and the served page does not. The practical rule: check the page that is served, in each language, without cookies, after the workers have restarted.



Ogma Events, the site behind these examples


Ogma Events is a speakers bureau based in Luxembourg and is one of the service that Dator.lu is providing. It selects and manages speakers, hosts and experts for corporate events in France, Belgium, Luxembourg and Switzerland. After more than ten years and 1,000+ engagements placed, it applies the same https://www.ogma-events.com/processus-selection to every request: needs analysis, a shortlist of three profiles, booking and contract, preparation and debrief. It only proposes speakers it knows personally and has seen on stage. https://www.ogma-events.com/tarif-conferencier for a standard keynote run from €2,000 to over €50,000, exclusive of VAT and travel.


If you organise a conference or seminar and need a speaker, including https://www.ogma-events.com/conferencier-en-anglais, Ogma Events comes back with a shortlist of three available profiles within 48 hours. For development, customisation or migration work on a multilingual Odoo website, 

Connecting Odoo Helpdesk and GitLab issues