A production-ready example of OpenUI chat with full thread persistence using Supabase.
Demonstrates:
- Per-user thread ownership via Supabase anonymous auth and Row Level Security
- Full CRUD persistence — thread list, message history, rename, delete
- Real-time sidebar updates using Supabase Realtime (postgres_changes)
- OpenAI-compatible streaming with message history saved to Postgres after each turn
threadApiUrlwiring — the canonical way to connect OpenUI to a backend
- Node.js 18+ and pnpm, npm, or Bun
- A Supabase project (free tier is fine)
- An OpenUI Cloud API key
Sign up at supabase.com and create a new project. Make a note of your Project URL and anon/public key (Settings → API).
In the Supabase dashboard go to Authentication → Providers and enable the Anonymous provider.
Open the Supabase dashboard, navigate to SQL Editor, and paste the contents of:
supabase/migrations/20240101000000_create_chat_tables.sql
Then click Run.
npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db pushThe migration creates:
| Object | Purpose |
|---|---|
threads table |
One row per chat conversation |
messages table |
One row per message, linked to a thread |
| RLS policies | Users can only read/write their own rows |
update_updated_at trigger |
Keeps threads.updated_at fresh |
| Realtime publication | Enables the postgres_changes subscription in the UI |
cp .env.local.example .env.local| Variable | Where to find it |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase dashboard → Settings → API → Project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase dashboard → Settings → API → anon/public key |
THESYS_API_KEY |
console.thesys.dev/keys |
Enter this standalone example:
cd examples/miscellaneous/supabase
pnpm install --ignore-workspace
pnpm devOpen http://localhost:3000.
On first visit supabase.auth.signInAnonymously() creates a stable anonymous user ID stored in a browser cookie. Every thread is scoped to this ID via Row Level Security — even anonymous users' data is fully isolated.
When you want to add traditional sign-in, call supabase.auth.updateUser({ email, password }) to upgrade the anonymous session to a permanent account. All existing threads transfer automatically.
threadApiUrl="/api/threads" tells OpenUI to use the default endpoint contract:
| Hook | Method | Route | Purpose |
|---|---|---|---|
fetchThreadList |
GET |
/api/threads/get |
Sidebar thread list |
createThread |
POST |
/api/threads/create |
New thread on first message |
loadThread |
GET |
/api/threads/get/:id |
Restore message history |
updateThread |
PATCH |
/api/threads/update/:id |
Rename thread |
deleteThread |
DELETE |
/api/threads/delete/:id |
Remove thread |
messageFormat={openAIMessageFormat} keeps two paths consistent:
- Live chat —
processMessageconverts to OpenAI format before sending to/api/chat - Thread loading —
loadThreadreceives OpenAI-format messages from/api/threads/get/:idandmessageFormat.fromApi()converts them back
Messages are stored in OpenAI format in the messages table so both paths use the same representation.
User types message
→ ChatProvider calls createThread (first message only)
→ POST /api/threads/create → INSERT into threads
→ ChatProvider calls processMessage
→ POST /api/chat with { messages, threadId }
→ OpenUI Cloud streams assistant reply
→ After stream: DELETE + re-INSERT all messages for thread_id
→ User reopens thread
→ ChatProvider calls loadThread
→ GET /api/threads/get/:id → SELECT messages
A Supabase Realtime channel subscribes to postgres_changes on the threads table. When the thread list changes in another tab or device, the subscription fires and remounts ChatProvider (via a React key change) so the sidebar refreshes automatically.
Note: remounting resets any in-progress conversation in the current tab. For a smoother experience, replace the
keytrick with a fine-grained state merge.
examples/miscellaneous/supabase/
├── .env.local.example
├── supabase/
│ └── migrations/
│ └── 20240101000000_create_chat_tables.sql
└── src/
├── middleware.ts # Refreshes Supabase session on every request
├── lib/
│ └── supabase/
│ ├── browser.ts # Browser client (Client Components)
│ └── server.ts # Server client (Route Handlers)
└── app/
├── layout.tsx
├── page.tsx # Chat UI + anon auth + Realtime subscription
└── api/
├── chat/
│ └── route.ts # LLM streaming + message persistence
└── threads/
├── get/
│ ├── route.ts # List threads
│ └── [id]/route.ts # Load thread messages
├── create/
│ └── route.ts # Create thread
├── update/
│ └── [id]/route.ts # Rename / update thread
└── delete/
└── [id]/route.ts # Delete thread
- AI-generated titles — replace the first-message excerpt in
POST /api/threads/createwith a short LLM call that names the conversation based on its content. - Upgrade anonymous users — add an email/password sign-up form and call
supabase.auth.updateUser()to convert anonymous sessions to permanent accounts. - Append-only message writes — instead of deleting and re-inserting all messages on every turn, track a
positioncolumn and only insert new rows. - Shared threads — extend the RLS policies to include a
thread_membersjoin table so threads can be read by invited users. - Cursor-based pagination — the
fetchThreadListresponse supports anextCursorfield; addLIMIT/OFFSET(or keyset pagination viaupdated_at) to/api/threads/getwhen thread counts grow large.
- Connect Thread History — the persistence API reference this example implements
- OpenUI Chat Quick Start
pnpm verify