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.

1

Install

code
npm install @mordn/chat-widget ai @ai-sdk/react \
  drizzle-orm postgres @supabase/supabase-js @ai-sdk/anthropic
npm install -D drizzle-kit

Omit @supabase/supabase-js if you do not need uploads. Replace @ai-sdk/anthropic with the provider package you use.

2

Scaffold the backend

code
npx @mordn/chat-widget

The wizard creates four files:

FilePurpose
app/api/chat/[[...chat]]/route.tsCatch-all route for chat, history, uploads, memory, and feedback
lib/chat-auth.tsThe getChatUserId stub you must implement
drizzle.config.tsPoints Drizzle Kit at the current chat schema
.env.exampleRequired environment-variable template
3

Implement the identity boundary

code
lib/chat-auth.ts
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.

4

Configure persistence, storage, and the model

code
.env.local
DATABASE_URL="postgres://..."
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="..."
code
app/api/chat/[[...chat]]/route.ts
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.

5

Create the tables and private bucket

code
npx drizzle-kit push

This creates chat_conversations and chat_messages. If you keep createSupabaseStorage(), create a private Supabase Storage bucket named chat-attachments. Never make it public.

6

Mount the widget

code
components/assistant.tsx
'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:

code
components/assistant.tsx
<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 cors and requestCredentials deliberately.

Next steps