| name | svelte-odoo-pwa |
| description | Build or extend an offline-capable Progressive Web App with a SvelteKit frontend backed by an Odoo SaaS + Studio custom model over JSON-RPC. Use this skill whenever the user wants a Svelte/SvelteKit app that reads or writes Odoo data, an Odoo-backed PWA, a mobile-installable app on top of Odoo Studio models, or a server-side JSON-RPC proxy to Odoo with API-key auth — even if they don't say "PWA" or "JSON-RPC" explicitly. Triggers include: "connect my Svelte app to Odoo", "Odoo Studio backend with a Svelte frontend", "add an offline expense/inventory/order app on Odoo", "JSON-RPC proxy to Odoo from SvelteKit", "make my Odoo app installable on phones", or extending the existing expense-split-pwa pattern (caching store, sync queue, balance calculations, x_studio_ fields). Also covers turning the app into a multi-tenant SaaS — login/signup, per-user Odoo company + user, session (httpOnly cookie) auth, and create_uid record-rule isolation — and deploying the server proxy on Vercel (adapter-vercel) with a known-good package set. Triggers also include: "add login/signup to my Odoo app", "multi-tenant", "each user only sees their own data", "isolate user data in Odoo", "record rule", "deploy SvelteKit + Odoo proxy to Vercel", or a failed static build of an app that has a server route. Reach for this skill before hand-rolling Odoo auth, relational-field formatting, PWA/offline plumbing, multi-tenant session auth, or the deploy adapter — those are the parts people get wrong. |
SvelteKit + Odoo Studio PWA
This skill builds (or extends) a full-stack pattern: a SvelteKit PWA
frontend that talks to an Odoo SaaS instance with Studio custom models
through a SvelteKit server-side JSON-RPC proxy. Optional layers stack on top:
an offline-first cache + sync queue (IndexedDB), and a multi-tenant SaaS
mode (login/signup, session auth, per-user company + create_uid isolation)
deployed with adapter-vercel.
It exists because three things in this stack are easy to get wrong and tedious
to rediscover each time: Odoo's authentication and relational-field encoding,
keeping Odoo credentials off the client, and PWA/offline plumbing that survives
a static deploy. The patterns here are battle-tested from a real roommate
expense-splitting app and generalize to any single-model CRUD app (inventory,
orders, bookings, time tracking, etc.).
When to use which part
Read only the reference file you need — they are independent.
| You're doing this | Read |
|---|
| Setting up / naming the Odoo Studio model and fields, API key auth | references/odoo-backend.md |
| Writing the SvelteKit server route that proxies to Odoo | references/server-proxy.md |
| Writing the browser-side client, caching store, derived data | references/frontend-client.md |
| Adding offline support, install-to-homescreen, background sync | references/offline-pwa.md |
| Making it multi-tenant: login/signup, sessions, per-user data isolation | references/saas-auth.md |
| Choosing the deploy adapter (Vercel vs static) and pinning packages | references/deploy.md |
Storing an evolving nested schema in one JSON Text field (x_studio_json) | references/json-text-field.md |
Ready-to-copy starting files live in assets/:
server.js (the proxy), odoo-client.js (the browser client),
vite.config.js (PWA config), svelte.config.js (static adapter),
layout.js (SPA mode), package.json (known-good versions), and .env.example.
Architecture in one picture
Svelte component
↓ (import)
caching store (localStorage) ──reads cached data instantly──▶ UI
↓
browser Odoo client (odoo-client.js) ── fetch POST {action, data} ──▶
↓
SvelteKit server route /api/odoo/+server.js ← credentials live ONLY here
↓ (JSON-RPC over HTTPS, API key auth, cached UID)
Odoo SaaS → Studio custom model (x_yourmodel)
The non-negotiable rule: Odoo URL, DB, username, and API key never reach the
browser. They are read with $env/static/private inside the +server.js
route. The browser only ever calls your own /api/odoo endpoint with a small
{ action, data } envelope.
Build order (new app)
Work backend-out so each layer can be tested before the next is built.
- Odoo backend — create the Studio model and fields, generate an API key.
Verify with a raw
curl JSON-RPC call before writing any JS. See
references/odoo-backend.md.
- Scaffold SvelteKit —
npm create svelte@latest, then switch to the
static adapter and SPA mode (ssr: false). Copy assets/svelte.config.js
and assets/layout.js. See references/offline-pwa.md for why.
- Server proxy — drop in
assets/server.js at
src/routes/api/odoo/+server.js, wire the .env. Confirm a search
action returns rows. See references/server-proxy.md.
- Browser client + store — copy
assets/odoo-client.js to
src/lib/odoo.js, build a caching store on top. See
references/frontend-client.md.
- PWA + offline — add
vite-plugin-pwa, icons, manifest; optionally add
the IndexedDB + sync-queue layer for true offline writes. See
references/offline-pwa.md.
- (Optional) SaaS — add login/signup, session auth (httpOnly cookie),
per-signup Odoo company + user, and a
create_uid record rule so each user
only sees their own data. Switch data calls from the admin key to the user's
session. See references/saas-auth.md.
- Deploy — if you have any server route (you do — the proxy), you can't use
a pure static host. Use
adapter-vercel (or adapter-node) and copy
assets/package.json for a compatible dependency set. See references/deploy.md.
Extending the existing expense-split-pwa
If the user already has the app and wants changes, the highest-leverage facts:
- The live Odoo model is
x_expensesplit; custom fields carry the Studio
prefix x_studio_ (e.g. x_studio_value, x_studio_who_paid,
x_studio_participants, x_studio_is_done, x_studio_date,
x_studio_expensegroup). The auto-created name field is x_name. New
fields added in Studio will be x_studio_<snake_name> — match that exactly
in code or reads silently return nothing.
- Adding a new action (e.g. group CRUD) requires changes in two files: a
new
case in the +server.js switch and a method on the client in
src/lib/odoo.js. The client already calls create_group/update_group/
delete_group actions that the proxy does not yet implement — a common
"why does this 400?" trap. See references/server-proxy.md.
- The cache store keys localStorage per group (
expense_cache_v4_group_<id>),
treats data as stale after 5 min, and background-syncs every 3 min, fetching
only records with id > lastRecordId. Bump the _v4_ version string when you
change the cached record shape, or stale shapes will linger in users'
browsers.
- Balance math lives in
src/lib/expenseUtils.js and runs in two phases
(settled "opening balances" first, then unsettled). Positive balance = owed
money; negative = owes. Settlement uses a greedy creditor/debtor match.
Gotchas worth stating up front (they cause real bugs)
- API key, not password. Odoo SaaS auth passes the API key in the password
position of both
common.login and object.execute_kw. See
references/odoo-backend.md.
- Relational fields are encoded, not plain. many2one: send an integer id,
receive
[id, "Name"]. many2many: send [[6, 0, [id1, id2]]], receive an
array of [id, "Name"] tuples. The helpers formatMany2one /
formatMany2many in the client exist for exactly this.
- A server route can't ship on
adapter-static. The proxy (and any
/api/auth/*) is a +server.js endpoint — adapter-static can't run it, so
the build fails or the endpoint 404s in production. Use adapter-vercel /
adapter-node; keep adapter-static only for a pure static frontend whose
proxy lives on another origin. Mark endpoints export const prerender = false.
See references/deploy.md.
- SPA shell still works on a server adapter.
ssr: false, csr: true,
prerender: true gives a static page shell and serverless endpoints — you
don't need adapter-static for the SPA feel. (For adapter-static, also set
fallback: 'index.html' or deep links 404 on refresh.)
- Multi-tenant isolation is a record rule, not frontend code. With SaaS auth,
data calls run as the user's session, but nothing isolates rows until you add
an Odoo record rule (
[('create_uid','=',user.id)], applied to R/W/C/D). A
direct API caller bypasses any client-side filter. See references/saas-auth.md.
- Company isolation has sharp edges;
create_uid doesn't. A custom
x_studio_company_id isn't auto-filled on API creates (only the built-in
company_id is), and the stock rule's ('company_id','=',False) branch makes
empty-company rows visible to everyone — the usual "search returns another
company's row" leak. For one-user-per-tenant, prefer create_uid. Either way,
hard-scope the search domain in the proxy as defense-in-depth. See
references/saas-auth.md.
- Pin a compatible package set. Svelte 5 needs
@sveltejs/vite-plugin-svelte ^7; adapter-vercel ^6 peers kit ^2. Mismatches (e.g. an ancient
@sveltejs/kit ^0.0.x pulled in by a bad edit) break npm run dev and drag in
vulnerable transitive deps. Copy assets/package.json and regenerate the lock.
- Split hosting. A static frontend (e.g. GitHub Pages) can't run the server
proxy. The client reads
PUBLIC_API_URL to point at a proxy hosted elsewhere
(Vercel/Render). When it's empty it assumes same-origin /api/odoo.
- Offline writes need a queue, not just a cache. Caching only helps reads.
For writes that survive being offline, enqueue the operation in IndexedDB and
drain the queue when
navigator.onLine flips true. See
references/offline-pwa.md.
- iOS safe areas. With
viewport-fit=cover, a translucent status bar, or an
installed standalone PWA, content slides under the notch — top-corner buttons
become untappable and top-anchored toasts get clipped. Pad the top-level
container and any top-fixed overlay with env(safe-area-inset-*). See
references/offline-pwa.md.
- JSON-in-a-Text-field: default-merge, don't replace. Stashing an evolving
nested schema in one
x_studio_json Text field avoids a Studio field per
metric, but the parser must start from a defaulted emptyDay() and merge each
leaf onto it. Replacing a nested sub-object silently drops newly-added keys on
older rows (a real bug we hit). See references/json-text-field.md.
Verify your work
After any change, sanity-check end to end rather than trusting the layers in
isolation:
- Hit the proxy directly (
curl -X POST .../api/odoo -d '{"action":"search","data":{"domain":[],"fields":["id"]}}')
to prove auth + model name + field names are right, independent of the UI.
- In the browser, confirm no Odoo credentials appear in the Network tab or
bundle — only calls to your own
/api/odoo.
- For PWA changes, run a production build and check the service worker
registers and the manifest validates (Chrome DevTools → Application tab).
- For offline changes, throttle to "Offline" in DevTools, perform a write, go
back online, and confirm the queued op syncs and the temp/local id is
reconciled with the server id.
- For SaaS isolation, create two accounts, add data to each, and confirm neither
can see the other's rows — then test it the hard way: while logged in as user
A,
curl/devtools a search for a record id you know belongs to user B and
confirm Odoo returns nothing (proves the record rule, not just the UI, isolates).