Odoo
How We Built Our Own Headless Blog on Odoo
Why we skipped Ghost, kept our content next to our leads, and the two things that almost went wrong
Every company reaches the same small, boring decision eventually: where does the blog live? We reached it a few weeks ago, and the obvious answers — a hosted platform, a folder of files in the same repository as the website — both looked wrong for reasons that had nothing to do with blogging software.
So we built the blog on the one system that already runs the rest of the business: Odoo. Not because it is the trendy choice — it is almost certainly not — but because it was the honest one, and because we are an Odoo partner, and shipping our own integration on our own CRM is the kind of thing that either proves the point or embarrasses us in public. This is the version where it proved the point, and the two places it almost didn't.
The boring choice everyone skips
A hosted blogging platform is a second service to keep alive: a second login, a second bill, a second place a former employee might still have access to. A folder of Markdown files in the website's own repository is the opposite problem — cheap to run, but publishing an article becomes a job for whoever knows how to open a pull request, which in practice means it rarely gets done at all.
Odoo was already running. It already had our team's logins, our permission groups, our CRM — and, not incidentally, the same leads a blog post is supposed to help generate. An article and the lead it produces living in the same database is not a coincidence we engineered around; it is the actual reason to do this.
One article, two languages — and why a translation field was the wrong tool
Odoo has a built-in way to translate a field: mark it
translate=True
and fill in the other language in a side dialog. It works well for a product name. It does not work for an article, for a reason that has nothing to do with Odoo and everything to do with what a translated article actually is.
A real bilingual article is almost never the same text twice. The example that lands with a Spanish-speaking reader is not the one that lands with an English-speaking one; the title that works as a headline in one language reads flat in the other. Treating that as "one source text plus a translation" is not simplifying the model — it is misdescribing the two independent pieces of writing that actually exist.
So each language is its own full record, and a stable, invisible key ties the two together — the same key, never shown in any URL, never translated, never touched again once it is set. It is the same idea our website already uses for everything that exists in two languages: a stable identifier for the relationship, and a localized slug for the address a reader actually sees.
An API that fails loud
The website reads articles over a small, deliberately boring HTTP API — plain REST, not the RPC protocol Odoo uses internally, because the thing on the other end is a build process, not a browser, and a build process should be debuggable with a single
curl
command.
The rule that mattered most while building it was simple: fail loud, or don't fail at all. If the content key is missing, the API refuses every request with a clear error, on purpose — a blog that quietly serves itself with no key configured is a worse outcome than a blog that briefly does not build. If a slug happens to exist in both languages at once, the API refuses to guess which one a reader meant, because guessing wrong means an article is silently published in the wrong language and nobody notices until a reader mentions it.
The one field that could have gone wrong quietly
The article body is stored as raw HTML, saved without Odoo's usual sanitizer running on the way in — the standard filter is aggressive enough to strip formatting a real article needs. That is a reasonable trade to make once. It is also, if left there without a second thought, a way to let anything typed into that field reach a public website unfiltered.
Before this went anywhere near a reader, we went back through it specifically looking for that kind of gap — and found it. The fix moves the filter to the other end of the pipe: nothing sanitized on the way in, everything sanitized on the way out, at the one place all of it has to pass through regardless of who wrote it or how. The one exception is embedded video, which the filter would otherwise strip entirely; those survive only from a short list of approved domains, and only after being rebuilt from nothing but their source URL — so nothing an editor typed survives in an embed except the address it points to.
From "Publish" to a page on the internet
The website itself is static — the same file served to every visitor, generated once and cached at the edge, which is most of why it is fast. That creates one real tradeoff worth naming honestly: the site does not ask Odoo anything while a visitor is looking at it. It asks once, while it is being built.
Which means publishing an article does not put it online by itself — it triggers a rebuild, and the rebuild is what actually puts it online, usually inside a minute or two. Editing an already-published piece behaves differently on purpose: instead of rebuilding on every keystroke, corrections get batched and flushed together every ten minutes, so that an afternoon of small edits costs one rebuild instead of twenty.
The bug that never reached a reader
The most useful bug we found here never touched a single visitor, because it was caught while the pipeline was still being built and tested against fake data before it ever pointed at anything real.
The build process asks Odoo for every article in both languages in as few requests as it reasonably can. The first version of that request asked for everything at once, without saying which language it wanted — and an article's tag names and author bio are the kind of content Odoo does translate natively, resolved against whichever language the request happens to be asking in. Ask for both languages in one breath, and every tag on every article comes back in whichever single language the request defaulted to — regardless of which language the article itself was actually written in. A Spanish article would have quietly shown its topic tag in English, on every page, with no error anywhere to point at it.
The fix was one request per language instead of one request for everything, which is a smaller change than the bug deserved. Catching it before a single real article existed is the entire point of testing a pipeline against fake data before pointing it at real content — the failure mode we were actually testing for was exactly this: correct-looking output that is quietly wrong in a way nothing would flag on its own.
What this actually is
None of this is a story about blogging software. It is what an ordinary week of integration work looks like when the client is honest about what they need, the constraints are real instead of assumed, and someone actually checks the dangerous parts before calling it done instead of after a reader finds them.
It happens to be Odoo this time because Odoo is what we already run — and because we build exactly this kind of thing, on exactly this kind of system, for clients who are not us. If your team is sitting on a similar case — content, or leads, or an operational process — that a system you already have could serve, we would like to hear what it is.
Have a technology challenge in mind?
Tell us what you are trying to solve. We will help you find the right path — and if there isn't one, we will say so.
Let's talk