Bring your own database
Everything in the Quickstart applies here — same widget, same handler, same identity boundary. The difference is where conversation rows and uploaded files live: your Postgres and your bucket instead of ours.
Take this path when data residency, an existing schema, or a hard no-third-party rule makes hosted persistence a non-starter. It costs you a database, a migration step, and a model-provider key.
Note
Prerequisites: everything in the Quickstart, plus a Postgres database, and — if you want file uploads — a Supabase project.
Install
npm install @mordn/chat-widget ai @ai-sdk/react \
drizzle-orm postgres @supabase/supabase-js @ai-sdk/anthropic
npm install -D drizzle-kitOmit @supabase/supabase-js if you do not need uploads. Replace
@ai-sdk/anthropic with the provider package you use.
Scaffold the backend
npx @mordn/chat-widgetThe CLI scaffolds this path specifically. It creates four files:
| File | Purpose |
|---|---|
app/api/chat/[[...chat]]/route.ts | Catch-all route for chat, history, uploads, memory, and feedback |
lib/chat-auth.ts | The getChatUserId stub you must implement |
drizzle.config.ts | Points Drizzle Kit at the current chat schema |
.env.example | Required environment-variable template |
Implement the identity boundary
import { auth } from '@clerk/nextjs/server';
export async function getChatUserId() {
const { userId } = await auth();
return userId;
}Warning
Never derive this id from X-User-Id, query parameters, or the request body.
Those values are controlled by the browser. Return null when unauthenticated;
the handler responds 401 before touching user data.
Configure persistence, storage, and the model
DATABASE_URL="postgres://..."
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="..."import { anthropic } from '@ai-sdk/anthropic';
import { createChatHandler } from '@mordn/chat-widget/server';
import { createDrizzleChatStore } from '@mordn/chat-widget/server/drizzle';
import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';
import { getChatUserId } from '@/lib/chat-auth';
export const { GET, POST, DELETE, OPTIONS } = createChatHandler({
getUserId: getChatUserId,
model: anthropic('claude-sonnet-4-5'),
store: createDrizzleChatStore(),
storage: createSupabaseStorage(),
});A model is required unless getHostedConfig supplies one. A store is currently
required. Omit storage to disable uploads. Export OPTIONS when you configure
cross-origin CORS.
Create the tables and private bucket
npx drizzle-kit pushThis creates chat_conversations and chat_messages. If you keep
createSupabaseStorage(), create a private Supabase Storage bucket named
chat-attachments. Never make it public.
Mount the widget
'use client';
import { ChatWidget } from '@mordn/chat-widget';
import '@mordn/chat-widget/styles.css';
export function Assistant() {
return <ChatWidget apiBase="/api/chat" />;
}To set appearance in code rather than publishing it from the dashboard, pass
config — the same canonical shape the bootstrap returns:
<ChatWidget
apiBase="/api/chat"
config={{
schemaVersion: 1,
runtime: { model: 'anthropic/claude-sonnet-4-5' },
client: {
greeting: 'How can I help?',
theme: {
backgroundColor: '#ffffff',
textColor: '#262626',
primaryColor: '#171717',
},
features: { fileUpload: true },
display: { layout: 'popup', size: 'default' },
starterPrompts: [{ title: 'What can you help me with?' }],
},
}}
/>Mixing the two
The axes are independent. You can keep conversations in your own Postgres while still using hosted configuration, knowledge, and feedback — useful when the data-residency rule is about message content, not about which model you picked.
import { createChatHandler } from '@mordn/chat-widget/server';
import { createDrizzleChatStore } from '@mordn/chat-widget/server/drizzle';
import {
createHostedConfig,
createHostedFeedback,
} from '@mordn/chat-widget/server/hosted';
const hosted = { apiKey: process.env.MORDN_CHAT_KEY! };
export const { GET, POST, DELETE } = createChatHandler({
getUserId: getChatUserId,
store: createDrizzleChatStore(),
getHostedConfig: createHostedConfig(hosted),
onFeedback: createHostedFeedback(hosted),
});See Choosing a backend for every combination.
Verify before shipping
- Two signed-in users cannot load, continue, delete, or attach files to each other's conversations.
- The attachments bucket is private.
- The route works after refresh and history reload.
- If the widget and handler are on different origins, configure
corsandrequestCredentialsdeliberately.