Quickstart
This guide mounts a secure chat backend and client widget in a Next.js App Router application. The only identity used for authorization must come from your verified server session.
Note
Prerequisites: Next.js 14/15/16, React 18 or 19, Vercel AI SDK ai v6,
@ai-sdk/react v3, and a model provider. The default self-hosted persistence
example also needs Postgres. Tailwind is not required; the widget stylesheet is
precompiled.
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 wizard 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" />;
}That is the whole component. On mount the widget calls
GET ${apiBase}/bootstrap, and the server returns only the config the browser
is allowed to see — greeting, theme, layout, features, starter prompts.
The widget never sends a userId. Identity is resolved on your server by
getChatUserId, so the browser cannot assert who it is or which history it
may read.
To set appearance in code instead of 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?' }],
},
}}
/>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.