Odoo as a Headless CMS: Serving Content to a Custom Frontend
Use Odoo as a headless CMS or commerce backend: keep content and catalog in Odoo, render a decoupled frontend, and read it over JSON-RPC through a gateway. Full walkthrough.
ODXProxy Team · Jul 11, 2026 · 6 min read

Teams increasingly want the editing power of Odoo without being tied to Odoo's website theming. The pattern is Odoo as a headless CMS: keep products, categories, blog posts, and content records in Odoo where staff already manage them, and render a completely separate frontend — a Next.js site, a mobile app, a static build — that pulls that content over the API. This decoupling is exactly what "headless" means: the backend (Odoo) holds the data and the head (your frontend) is swappable. This guide shows how to run Odoo as a headless CMS or commerce backend, what to read over JSON-RPC, and how to put a gateway in front so the arrangement is safe and cacheable.
What "headless Odoo" actually looks like
In a headless setup, Odoo stops being the thing that renders HTML to visitors and becomes a content API. The division of labor is:
- Odoo owns the data and the editing UX. Merchandisers manage products in the Sales/Inventory
apps, editors write posts in the Blog/Website module, and everything stays in Odoo's models
(
product.template,product.public.category,blog.post, and so on). - Your frontend owns presentation. It fetches content from the Odoo API at build time or request time, renders it however you like, and can be deployed anywhere — with none of Odoo's theming in the way.
The link between the two is a set of read calls. Because Odoo speaks JSON-RPC 2.0 (not REST),
you don't hit a URL per resource; you send the model and the query in the request body and change
model_id to fetch a different content type.
Why put a gateway in front of a headless Odoo
You could point your frontend straight at Odoo's endpoint, but a public-facing headless site is a bad place to expose raw ERP credentials or the full method surface. A gateway solves three problems at once:
- Credential isolation. Your frontend holds a single gateway key, not the Odoo database name, user id, and user API key. The Odoo credentials stay server-side.
- A restricted surface. The gateway allowlists a fixed set of actions, so even a leaked frontend key can only do what the allowlist permits — not call arbitrary model methods.
- A stable, uniform shape. Every content type comes back in the same JSON-RPC envelope, which is easy to wrap in a typed data layer and cache.
ODXProxy is that gateway: your frontend (or, better, your frontend's server/build step) calls
POST /api/odoo/execute with the gateway's x-api-key, and the proxy forwards the read to Odoo.
Reading a product catalog for a headless storefront
The most common headless-commerce read is "give me the catalog." A search_read on
product.template pulls the fields your frontend needs in one round trip:
{
"id": "cms-catalog-1",
"action": "search_read",
"model_id": "product.template",
"params": [[["sale_ok", "=", true], ["is_published", "=", true]]],
"keyword": {
"fields": ["name", "list_price", "default_code", "description_sale"],
"order": "name asc",
"limit": 40,
"offset": 0
},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}The pieces that matter for a headless read:
paramsis a JSON array; its first element is the domain filter. Here it's two conditions AND-ed together — only saleable, published products reach the storefront.keywordis a JSON object carryingfields,order,limit, andoffset— everything you need for a paginated catalog. Ask only for the fields you render; narrow reads are faster and cache smaller.
On success you get HTTP 200 and the records in result:
{
"jsonrpc": "2.0",
"id": "cms-catalog-1",
"result": [
{ "id": 8, "name": "Acoustic Panel", "list_price": 39.0, "default_code": "AP-01", "description_sale": "Studio-grade sound absorption." }
]
}Fetching a single content page
For a detail page — one product, one blog post — resolve by a stable identifier and read the body
fields. Blog content lives in blog.post; a slug-style lookup keeps your URLs clean:
{
"id": "cms-post-1",
"action": "search_read",
"model_id": "blog.post",
"params": [[["website_slug", "=", "launching-our-2026-lineup"], ["is_published", "=", true]]],
"keyword": { "fields": ["name", "content", "post_date", "author_id"], "limit": 1 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}A single call returns the page's title, HTML content, publish date, and author reference. Your frontend renders that into its own template — the head is entirely yours.
author_id come back as a two-element pair of id and display name. To traverse deeper — the author's bio, a category tree — use the relational reads covered in Odoo relational domain filters.Keeping the frontend fast: cache and revalidate
A headless frontend shouldn't call Odoo on every visitor request — that couples your page latency to ERP load. Two habits keep it fast:
- Read at build time or cache server-side. Static-generate catalog and content pages, or cache the
gateway responses in your frontend layer with a sensible TTL. The JSON-RPC envelope is easy to
memoize by request
id/query. - Revalidate on change, not on a clock alone. When staff publish or edit in Odoo, trigger a rebuild or cache purge of the affected pages (an Odoo automation or webhook into your frontend's revalidation hook). That's the same content-sync discipline described in the two-way Odoo data sync guide — here it's one-way, Odoo → frontend.
Because catalog and content reads are the same nine allowlisted actions everything else uses, you can
add write-back later (a contact form creating a crm.lead, say) without changing the integration
shape — just switch action to create.
Handle the HTTP 200 trap even on reads
Read calls fail too, and Odoo's failure mode is the one that surprises people: an HTTP 200 can
still carry an error. Only gateway-layer problems (bad x-api-key, unreachable Odoo) use a
non-200 status. An access error on a model your integration user can't see comes back as a 200 with
a populated error:
{
"jsonrpc": "2.0",
"id": "cms-catalog-1",
"error": {
"code": 200,
"message": "You are not allowed to access 'Product Template' records.",
"data": { "name": "odoo.exceptions.AccessError" }
}
}So even for a read-only headless frontend, check the HTTP status first, then check the body's error
field before you render result. Give the headless integration user read access to exactly the
models it serves and nothing more.
Where to go next
- Sync changes the other direction — external systems into Odoo — with the two-way Odoo data sync pattern, or see a concrete build in the Odoo + Shopify integration guide.
- Traverse related content cleanly with Odoo relational domain filters.
- Read the full request/response contract in the API reference, or reach for the Python SDK to wrap the envelope and error checks.