Every Odoo developer has faced this at least once before. The customer requires that "the website should be translated into three languages." The functional consultant installs the required languages, adds some translations, and thinks everything is finished. However, somebody needs a custom controller, which should work differently depending on the current language; another person requests a missing x-default hreflang tag, and the last one needs to disable the redirection of the language switcher to the website's home page...and here comes the development part!
The main topic of the article below does not include the obvious recommendation "just go to Settings and choose your language" since it has been already discussed earlier. The current article will tell about the internal mechanisms of Odoo 19 in case of serving pages in several languages.
How Odoo Resolves a Page's Language
As soon as a user clicks on a URL of a website, Odoo does not keep separate copies of a web page in different languages; rather, it determines which language should be used to render the page prior to its rendering by using the URL prefix.
There is an attribute named url_code for each res.lang record installed (e.g., fr for French, de_DE for German in case the short code conflicts), and if a website supports several languages, each of them, except the default website language, receives a URL prefix:
- https://example.com/shop - default language
- https://example.com/fr/shop - French
- https://example.com/de_DE/shop - German
During request dispatch, the frontend router removes the prefix from the URL, compares the result with active res.lang.url_code values on the website, and adds the found language to the request context. From that moment onward, request.lang (which is a res.lang recordset in new Odoo versions, not a simple string anymore) is used by every ORM request, QWeb rendering, and ir.ui.view lookup to choose the proper translation.
If none of the language codes in the URL corresponds to a loaded language, Odoo gracefully defaults to the website’s default_lang_id without producing any 404 errors.
Where the Translated Content Actually Lives
This is a trap for many developers who think from the “one record per language” perspective. One website.page is not created per language; rather, there is one record of page, one ir.ui.view, and the translatable content of the view, which is arch_db and the other Char/Text/Html fields, in JSON format and indexed by the language code right within the column.
So when you inspect arch_db on a page's view record, you're really looking at something shaped like:
{
"en_US": "<div>Welcome</div>",
"fr_FR": "<div>Bienvenue</div>"
}Odoo ORM will automatically select the proper key by taking into consideration request.lang or lang from the context and then give you just a string — you never work with the dictionary object until you write migrations or use plain SQL.
The bottom line is that while customizing cyllo_studio’s report or page editor and having to read/write something for a certain language, you do not look for another record but just alter the context.
# Writing French content into the same page record
page = env['website.page'].browse(page_id)
page.with_context(lang='fr_FR').write({
'arch_db': new_html_content,
})
# Reading back what's stored for German, regardless of the
# language the current request happens to be in
german_view = page.view_id.with_context(lang='de_DE')
print(german_view.arch_db)
Building a Language-Aware Controller
When designing your own controller and need it to provide different content depending on the language, which happens often when the module displays something that is not part of the regular page/view workflow, let the routing do the job instead of manually parsing the URL.
from odoo import http
from odoo.http import request
class MultiLangPageController(http.Controller):
@http.route(
['/custom-page', '/<string:lang>/custom-page'],
type='http', auth='public', website=True, sitemap=True
)
def custom_page(self, lang=None, **kwargs):
# request.lang is already resolved correctly here — no need
# to re-parse the lang param yourself
active_lang = request.lang.code
values = {
'active_lang': active_lang,
}
return request.render('my_module.custom_page_template', values)
The website=true attribute is handling everything; it ensures that your language middleware fires before your method gets called, which guarantees that request.lang is ready to be used right from the get-go. Otherwise, you will wind up writing language detection functionality all wrong.
Closing the Hreflang Gaps
hreflang alternative links are created automatically by Odoo for all live languages in your website. The most common failure is x-default – this indicates which URL should be served when the visitor’s browser language does not match any of those provided. This is something that needs to be explicitly set to avoid search engine bots from picking arbitrarily.
A simple way to fix this problem is through an inheriting template:
<template id="add_hreflang_x_default" inherit_id="website.layout" name="Add x-default hreflang">
<xpath expr="//head" position="inside">
<t t-if="website and len(website.language_ids) > 1">
<link rel="alternate" hreflang="x-default"
t-attf-href="#{website.domain or request.httprequest.url_root}#{request.httprequest.path}"/>
</t>
</xpath>
</template>
Drop this in your website customization module, deploy, and re-check Google Search Console's International Targeting report after the next crawl — the missing-tag warnings should clear up.
Customizing the Language Selector
The inbuilt selector handles Dropdown/Inline options and Text/Flag/Flag and Text/Code/Flag and Code labels without any need for coding. The complicated part comes when you want functionality that cannot be accessed through the builder, such as staying on the same page when changing the language rather than being redirected to the homepage and displaying the full name of the language rather than the language code.
This selector is created using a QWeb template (website.language_selector and its counterparts), and thus the normal process would involve inheriting the template and then looping through website.language_ids manually and constructing the href by replacing the language code in the URL of the current page.
A Few Gotchas Worth Knowing Before You Start
- View cache is language-specific. If you are troubleshooting "why can’t I see my changes?" on a localization page, make sure you’re working on the language version that you thought you were because the arch_db lookup is contextually silent.
- It’s also important to think about layout for RTL languages, not just translation. Not only does the text flip, but also any elements that may have been hardcoded using float: left or margin-left in custom snippets.
- Sitemaps take into consideration the URL scheme for each language. When using custom routes with sitemap=True, ensure that they can be accessed via all languages available or face partial indexing.
- Menu labels are also translatable fields, just like page contents, using the same approach – the JSON-column – so if your menu label fails to translate, it is probably due to a lack of translations for that particular language.
While multi-language functionality works perfectly out-of-the-box in Odoo 19 in the most common scenarios, customization in the sense of creating custom controllers, full SEO implementation, and using non-standard selectors still requires knowing about this model of a single record with multiple translations.
To read more about How to Manage the Language of a Website in Odoo 18, refer to our blog How to Manage the Language of a Website in Odoo 18.