Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions examples/supabase-chat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
190 changes: 190 additions & 0 deletions examples/supabase-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# OpenUI × Supabase Chat

A production-ready example of [OpenUI](https://openui.com) chat with full thread persistence using [Supabase](https://supabase.com).

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
- **`threadApiUrl` wiring** — the canonical way to connect OpenUI to a backend

## Prerequisites

- Node.js 18+ and [pnpm](https://pnpm.io)
- A [Supabase](https://supabase.com) project (free tier is fine)
- An [OpenRouter](https://openrouter.ai) API key, or any OpenAI-compatible LLM provider

## Setup

### 1. Create a Supabase project

Sign up at [supabase.com](https://supabase.com) and create a new project. Make a note of your **Project URL** and **anon/public key** (Settings → API).

### 2. Enable anonymous sign-in

In the Supabase dashboard go to **Authentication → Providers** and enable the **Anonymous** provider.

### 3. Run the migration

#### Option A — SQL Editor (quickest)

Open the Supabase dashboard, navigate to **SQL Editor**, and paste the contents of:

```
supabase/migrations/20240101000000_create_chat_tables.sql
```

Then click **Run**.

#### Option B — Supabase CLI

```bash
npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db push
```

The 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 |

### 4. Configure environment variables

```bash
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 |
| `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
| `OPENROUTER_MODEL` _(optional)_ | Defaults to `openai/gpt-4o-mini` |

### 5. Install and run

From the repository root:

```bash
pnpm install
pnpm --filter supabase-chat dev
```

Or from this directory:

```bash
pnpm install
pnpm dev
```

Open [http://localhost:3000](http://localhost:3000).

---

## How it works

### Authentication

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.

### Thread persistence

`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 |

### Message format alignment

`messageFormat={openAIMessageFormat}` keeps two paths consistent:

1. **Live chat** — `processMessage` converts to OpenAI format before sending to `/api/chat`
2. **Thread loading** — `loadThread` receives OpenAI-format messages from `/api/threads/get/:id` and `messageFormat.fromApi()` converts them back

Messages are stored in OpenAI format in the `messages` table so both paths use the same representation.

### Message persistence flow

```
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 }
→ OpenRouter 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
```

### Real-time updates

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 `key` trick with a fine-grained state merge.

---

## Project structure

```
examples/supabase-chat/
├── .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
```

---

## Going further

- **AI-generated titles** — replace the first-message excerpt in `POST /api/threads/create` with 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 `position` column and only insert new rows.
- **Shared threads** — extend the RLS policies to include a `thread_members` join table so threads can be read by invited users.
- **Cursor-based pagination** — the `fetchThreadList` response supports a `nextCursor` field; add `LIMIT`/`OFFSET` (or keyset pagination via `updated_at`) to `/api/threads/get` when thread counts grow large.

## Related docs

- [Connect Thread History](https://openui.com/docs/chat/persistence) — the persistence API reference this example implements
- [OpenUI Chat Quick Start](https://openui.com/docs/chat/quick-start)
11 changes: 11 additions & 0 deletions examples/supabase-chat/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);

export default eslintConfig;
8 changes: 8 additions & 0 deletions examples/supabase-chat/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
turbopack: {},
transpilePackages: ["@openuidev/react-ui", "@openuidev/react-headless"],
};

export default nextConfig;
31 changes: 31 additions & 0 deletions examples/supabase-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "supabase-chat",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@openuidev/react-headless": "workspace:*",
"@openuidev/react-ui": "workspace:*",
"@supabase/ssr": "^0.5.0",
"@supabase/supabase-js": "^2.49.4",
"next": "16.1.6",
"openai": "^6.22.0",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
7 changes: 7 additions & 0 deletions examples/supabase-chat/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};

export default config;
96 changes: 96 additions & 0 deletions examples/supabase-chat/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createSupabaseServer } from "@/lib/supabase/server";
import { NextRequest } from "next/server";
import OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";

const MODEL = process.env.OPENROUTER_MODEL ?? "openai/gpt-4o-mini";

/**
* POST /api/chat
*
* Accepts an OpenAI-format message array and an optional threadId.
* Streams the assistant reply as Server-Sent Events (openai-completions format).
* After the stream finishes, persists the full conversation to Supabase so
* that loadThread can restore it when the user reopens the thread.
*/
export async function POST(req: NextRequest) {
const { messages, threadId } = (await req.json()) as {
messages: ChatCompletionMessageParam[];
threadId?: string | null;
};

// Create the Supabase client before streaming so the request context
// (cookies) is still available when we persist messages afterwards.
const supabase = await createSupabaseServer();

const client = new OpenAI({
apiKey: process.env.OPENROUTER_API_KEY,
baseURL: "https://openrouter.ai/api/v1",
});

const stream = await client.chat.completions.create({
model: MODEL,
messages,
stream: true,
});

let assistantContent = "";
const encoder = new TextEncoder();

const readable = new ReadableStream({
async start(controller) {
const enqueue = (data: Uint8Array) => {
try {
controller.enqueue(data);
} catch {
// Controller already closed (client disconnected)
}
};

for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
assistantContent += delta.content;
}
enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}

enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();

// ── Persist the full conversation ──────────────────────────────────────
// We replace all messages for this thread on every turn so that the
// stored history always reflects the current state. This is simple and
// correct; for large threads you may prefer an append-only strategy.
if (threadId) {
try {
const allMessages: ChatCompletionMessageParam[] = [
...messages,
{ role: "assistant", content: assistantContent },
];

await supabase.from("messages").delete().eq("thread_id", threadId);

await supabase.from("messages").insert(
allMessages.map((m) => ({
thread_id: threadId,
role: m.role,
content:
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
})),
);
} catch (err) {
console.error("[chat] Failed to persist messages:", err);
}
}
},
});

return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
Loading
Loading