Shipping a Headless Storefront on Next.js While Odoo Stays the System of Record
Odoo owns the business facts, the storefront owns the experience, and a thin server-side layer keeps them honest with each other.
2026-09-30 · By Gleni Isaku, Metanow
Most companies that run Odoo reach the same point sooner or later. The ERP works: sales, inventory, invoicing and purchasing live in one place, and the back office trusts the numbers. But the storefront has to be faster, more flexible and more on-brand than the built-in Odoo website makes easy. The obvious answer is to go headless: put a Next.js front end in front of Odoo and let each system do what it is good at.
The less obvious part is keeping it that way. Two years after launch, plenty of headless projects have quietly grown a second product database, a second set of prices and a checkout that disagrees with the warehouse. At Metanow we build Odoo, Next.js and Laravel systems for clients across Europe, and this article is about the boundary between the two systems: where it should sit, how data crosses it, and how to stop it from eroding.
1. Decide what "system of record" actually covers
Saying "Odoo is the source of truth" is easy. Writing down what that covers is the part that saves you later. Start every headless project with a short ownership table and get the business to sign it:
| Data | Owner | Storefront role |
|---|---|---|
| Products, variants, attributes | Odoo | Read, cache, render |
| Prices, pricelists, taxes | Odoo | Read, never compute independently |
| Stock / availability | Odoo | Read, re-check at checkout |
| Customers and addresses | Odoo | Create and update through the API |
| Orders and payment status | Odoo | Create, then read status |
| Marketing copy, landing pages, SEO text | CMS or the front-end repo | Owned by the storefront |
The rule that falls out of this: the storefront may cache Odoo data, but it may never be the only place a business fact lives. If a price, a discount rule or a stock number exists only in the Next.js app, sooner or later it will drift from what accounting sees.
Rich marketing content is the deliberate exception. Long-form product stories, campaign pages and editorial blocks usually belong in a CMS or in the front-end repository, not in Odoo's product form. Forcing them into the ERP turns product managers into web editors inside a tool that was never designed for it. The table makes that split explicit, so nobody argues about it six months in.
2. A thin integration layer: the browser never talks to Odoo
The first architectural decision is where the Odoo API calls happen. The answer should always be the same: on the server, never in the browser.
Odoo exposes its models through an external API. For years that meant XML-RPC and JSON-RPC (/xmlrpc/2, /jsonrpc). Odoo 19 introduced the JSON-2 API (/json/2/<model>/<method>, authenticated with an API key as a bearer token) and deprecated the legacy RPC endpoints,
which Odoo's documentation currently schedules for removal in Odoo 22. For a new build on a
current version, target JSON-2 from day one. Whatever the version, the API speaks in models
and methods (product.template, sale.order, search_read, create), and the credentials you use can see far more than a shopper ever should.
So put a thin backend-for-frontend in between. Depending on the project, that is either:
- •Next.js route handlers and server actions, when the integration is mostly reads plus a checkout flow, or
- •a separate service (for us, usually Laravel) when there is real business logic around the storefront: B2B account rules, payment and shipping integrations, queues, or several front ends sharing the same Odoo.
Either way, the layer does four jobs:
- Holds credentials. A dedicated Odoo integration user whose access rights cover exactly the models and fields the storefront needs. Not an admin, and not a real employee's account.
- Shapes data. It turns Odoo records into storefront types (
Product,Variant,Price,Availability), so React components never seeproduct.productIDs, many2one tuples orfalsewhere an empty string was meant. - Enforces rules. Which pricelist applies to this customer, which products are published, which warehouse counts for availability.
- Absorbs Odoo's pace. The ERP is optimised for correctness and back-office workflows, not for hundreds of concurrent storefront requests. The layer caches, batches and rate-limits, so a traffic spike does not become a slow ERP for the sales team.
In Next.js the core of that layer can be very small. A server-only helper for JSON-2:
// lib/odoo.ts
import "server-only";
const ODOO_URL = process.env.ODOO_URL!; // e.g. https://erp.example.com
const ODOO_KEY = process.env.ODOO_API_KEY!; // key of the integration user
export async function odoo<T>(
model: string,
method: string,
body: Record<string, unknown> = {},
): Promise<T> {
const res = await fetch(`${ODOO_URL}/json/2/${model}/${method}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `bearer ${ODOO_KEY}`,
// "X-Odoo-Database": process.env.ODOO_DB!, // only if several DBs share the domain
},
body: JSON.stringify(body),
cache: "no-store", // caching happens one level up, per use case
});
if (!res.ok) throw new Error(`Odoo ${model}.${method} failed: ${res.status}`);
return res.json() as Promise<T>;
} On Odoo 18 or older, the same helper posts to /jsonrpc instead, with {"service": "object", "method": "execute_kw", "args": [db, uid, key, model, method,
args, kwargs]} as the params. Keep that difference inside this one file and nothing else in the app has to care
which version you are on.
A practical tip: always pass a field list. search_read without fields returns
every field on the model, computed ones included, and on a product catalog that becomes a performance
problem you will not notice until the catalog grows.
3. Keeping catalog, prices and stock in sync
This is where most of the engineering time goes. Different data changes at different speeds, so treat each kind differently.
Catalog structure (products, categories, attributes) changes rarely. Render product and category pages statically or with long-lived caches, and tag every cached read with what it depends on:
// lib/catalog.ts
import { cacheTag, cacheLife } from "next/cache";
import { odoo } from "./odoo";
export async function getProduct(id: number, lang: string) {
"use cache"; // Next.js 16 with cacheComponents enabled; use unstable_cache on older versions
cacheTag(`product:${id}`);
cacheLife("days");
const [p] = await odoo<any[]>("product.template", "read", {
ids: [id],
fields: ["name", "description_sale", "list_price", "website_slug", "write_date"],
context: { lang },
});
return toProduct(p); // normalise Odoo's quirks into a storefront type
} What invalidates it is Odoo itself. Since Odoo 17, automation rules can send a webhook notification when a record is created or updated. Point one at a revalidation route in the integration layer, include a secret, and invalidate only the affected tags:
// app/api/odoo/webhook/route.ts
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
const url = new URL(req.url);
if (url.searchParams.get("token") !== process.env.ODOO_WEBHOOK_SECRET) {
return new Response("Forbidden", { status: 403 });
}
const payload = await req.json(); // Odoo sends _model, _id and the fields you selected
if (payload._model === "product.template") {
revalidateTag(`product:${payload._id}`, "max"); // Next.js 16 signature
}
return Response.json({ ok: true });
} A product edit in the back office now shows up on the storefront within seconds, without a rebuild and without polling. Odoo's outgoing webhook does not sign its requests, so a long random token in the URL (or an IP allow-list, or both) is the minimum.
Webhooks do get lost, though: a deploy at the wrong moment, a network blip, an automation rule
someone disabled while testing. So always add a scheduled reconciliation job. It asks Odoo
what changed since the last run, using write_date, and revalidates anything that slipped through:
// app/api/cron/reconcile/route.ts (called by a scheduler every few minutes)
import { revalidateTag } from "next/cache";
import { odoo } from "@/lib/odoo";
import { getCursor, setCursor } from "@/lib/sync-state";
export async function GET(req: Request) {
if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Forbidden", { status: 403 });
}
const since = await getCursor("product.template"); // "2026-09-29 10:00:00", UTC
const changed = await odoo<{ id: number; write_date: string }[]>(
"product.template", "search_read",
{ domain: [["write_date", ">", since]], fields: ["write_date"], order: "write_date asc" },
);
for (const p of changed) revalidateTag(`product:${p.id}`, "max");
if (changed.length) await setCursor("product.template", changed.at(-1)!.write_date);
return Response.json({ revalidated: changed.length });
} Webhooks for speed, reconciliation for correctness. Neither is enough on its own.
Prices are trickier because they depend on context: pricelists, customer groups, currencies, quantity breaks and taxes. The rule is that the storefront never reimplements Odoo's pricing logic. For anonymous visitors, cache the public pricelist price. For logged-in B2B customers, fetch prices from Odoo through the integration layer, cached per pricelist rather than per user, so that what the customer sees is exactly what ends up on the sales order and the invoice.
Stock changes constantly and matters most at the moment of purchase. Show cached availability on listing pages ("in stock", "low stock", "out of stock" rather than exact counts), fetch fresher numbers on the product page, and re-check live availability in Odoo during checkout. The number on a category page can be a few minutes old. The number that decides whether an order is accepted cannot.
4. Checkout that writes cleanly back to Odoo
Reads are forgiving. Writes are not. Checkout is where a headless build either earns the back office's trust or creates weekly cleanup work for it.
Let Odoo build the order. The cart can live in the storefront (a cookie or a
small session store) while the customer browses. At checkout, the integration layer finds or
creates the customer (res.partner), creates a sale.order with its lines, and lets Odoo compute taxes and totals. Show Odoo's totals back to the customer
before payment. If they differ from what the cart displayed, that is a bug you want to catch here,
not in accounting.
Make order creation idempotent. Customers double-click, browsers retry, and
serverless functions time out after the write succeeded. Generate a checkout key when the
checkout starts, store it on the Odoo order (in client_order_ref or a dedicated custom field), and look for it before creating anything:
// app/checkout/actions.ts
"use server";
import { odoo } from "@/lib/odoo";
import { getCart, getCheckoutKey } from "@/lib/cart";
export async function placeOrder(partnerId: number) {
const cart = await getCart();
const key = await getCheckoutKey(); // UUID created when checkout started, kept in the session
const existing = await odoo<number[]>("sale.order", "search", {
domain: [["client_order_ref", "=", key]], limit: 1,
});
if (existing.length) return { orderId: existing[0] }; // retry: same checkout, same order
const [orderId] = await odoo<number[]>("sale.order", "create", {
vals_list: [{
partner_id: partnerId,
client_order_ref: key,
order_line: cart.lines.map((l) => [0, 0, { product_id: l.variantId, product_uom_qty: l.qty }]),
}],
});
return { orderId };
} Search-then-create still leaves a small race window if two requests arrive at the same
instant. Close it with a lock on the key in the integration layer (a Redis SET NX works), or with a unique constraint on the custom field in a small Odoo module. One checkout,
one order, however many times the request arrives.
Treat the payment provider's webhook as the trigger, not the redirect. A customer who closes the tab after paying must still end up with a confirmed order. The redirect page only reads status. The server-side notification does the work:
// app/api/payments/webhook/route.ts (Stripe shown; other providers work the same way)
import Stripe from "stripe";
import { odoo } from "@/lib/odoo";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(
await req.text(), req.headers.get("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET!,
); // throws on a bad signature
if (event.type === "payment_intent.succeeded") {
const orderId = Number(event.data.object.metadata.odoo_order_id);
const [order] = await odoo<{ state: string }[]>("sale.order", "read", {
ids: [orderId], fields: ["state"],
});
if (order.state === "draft" || order.state === "sent") {
await odoo("sale.order", "action_confirm", { ids: [orderId] });
} // already confirmed: a duplicate delivery, safe to ignore
}
return Response.json({ received: true });
} Providers deliver the same event more than once, so the handler checks the order's state before acting. The same reconciliation idea from section 3 applies here too: a periodic job that lists orders still waiting for payment and asks the provider for their real status catches the webhook that never arrived.
Plan for abandoned checkouts. Draft quotations from abandoned carts pile up in Odoo and confuse sales. Decide early whether they should exist at all, how they are labelled, and when a scheduled job cancels them.
Write back as little as possible. The storefront creates customers, addresses and orders. It does not edit products, adjust stock or post accounting entries. The smaller the write surface, the easier it is to reason about when something goes wrong.
5. Production lessons: when headless is worth it
A few things that matter more after launch than they seem to before it:
Map Odoo's quirks once, in one place. Odoo returns false for empty values, many2one fields as [id, "Display Name"] pairs, and translated fields according to the context language. Normalise all of it in the
integration layer. If a React component ever checks product.description === false, the abstraction has leaked.
Pass language, company and pricelist explicitly. Multilingual and multi-company setups are common in Europe. Send the language (and, where relevant, the company and pricelist) in the context of every call instead of relying on the integration user's defaults, or a German storefront will occasionally serve English product names and nobody will know why.
Give images their own pipeline. Serving product images straight from Odoo
works in a demo and hurts in production. Put them behind next/image with a proper loader and a CDN, and cache aggressively.
SEO lives in the front end; the data comes from Odoo. Slugs, canonical URLs,
structured data (Product, Offer, BreadcrumbList) and sitemaps are generated in Next.js from Odoo data. Keep slugs stable: when a product is
renamed in Odoo, redirect the old URL instead of letting it 404.
Upgrade Odoo on purpose. Major versions change models, fields and, right now, the API itself. With every Odoo call behind one typed layer, an upgrade becomes a contained task: pin the version you integrate against, run the integration tests against a staging copy of the new one, then switch.
Monitor the boundary, not just the pages. A homepage uptime check tells you little. Watch Odoo response times, failed webhook deliveries, orders without payment confirmation and reconciliation drift. That is where problems show up first.
So, is it worth it? Headless adds a second system to build, host and operate. It pays off when the storefront needs real front-end freedom, serious performance and SEO work, several channels on one Odoo backend, or B2B flows the standard website does not handle well. It is often not worth it for a small catalog with a standard B2C checkout, where Odoo's own website and eCommerce apps are cheaper to run and good enough. And if the underlying problem is that Odoo itself is not configured around how you sell, fix that first; our notes on tailoring Odoo to your processes cover that side. A new front end will not rescue a back office that nobody trusts.
If you do go headless, the principle that matters most is the one from the start: Odoo owns the business facts, the storefront owns the experience, and a thin, well-tested layer in between keeps them honest with each other. Get that boundary right and both sides can evolve at their own pace, which is the whole point of going headless in the first place.
Guest post by
Gleni Isaku
Metanow, Durrës, Albania
Gleni Isaku works at Metanow, a web development and Odoo agency in Durrës, Albania, building Next.js storefronts, Laravel services and Odoo integrations for businesses across Europe.
Let's Build Together
Your vision,
our expertise.
From AI integration to full-stack development, we turn ambitious ideas into products that perform.