Gate the chat route and scope thread persistence to the signed-in user with Clerk.
Clerk is a hosted auth platform that integrates with Next.js through @clerk/nextjs. This page covers using it with assistant-ui without AssistantCloud: middleware-based route gating, server-side auth() checks, and per-user scoping on a custom thread list.
If you use AssistantCloud, see cloud authorization instead. Cloud handles the Clerk JWT exchange for you and gives you workspace-scoped threads with no DB code.
How it works
browser ──► clerkMiddleware (proxy.ts) ──► /api/chat
/api/threads/*
│
▼
auth() returns { userId }
│
▼
scope queries by userIdThree places Clerk touches the integration:
- Middleware runs before every request and sets up the auth context the rest of the app reads.
- API routes (
/api/chat,/api/threads/*) callawait auth()from@clerk/nextjs/server, return 401 whenuserIdis null, then scope DB queries by that id. - Reload-on-auth uses
useUserfrom@clerk/nextjsso the thread list re-fetches when the user signs in.
Clerk's auth() returns userId directly, so the route handler can scope queries without any callback configuration.
Setup
This guide assumes you already have Clerk configured in your Next.js app. If not, follow the Clerk Next.js quickstart first; the steps below pick up after <ClerkProvider> wraps your root layout and clerkMiddleware() is in place.
Gate the chat route
Add an auth() check to the AI SDK route. Return 401 before calling the model so unauthenticated traffic doesn't burn provider credits.
import { auth } from "@clerk/nextjs/server";
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages } from "ai";
import type { UIMessage } from "ai";
export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Scope thread queries by user
If you also use custom thread persistence, every thread-list endpoint must filter by userId. Without scoping, any signed-in user can list everyone's threads.
import { auth } from "@clerk/nextjs/server";
import { db } from "@/db";
import { threads } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
export async function GET() {
const { userId } = await auth();
if (!userId) return new Response(null, { status: 401 });
const rows = await db
.select()
.from(threads)
.where(eq(threads.userId, userId))
.orderBy(desc(threads.updatedAt));
return Response.json(rows);
}For organization-scoped threads (Clerk Orgs), pull orgId from auth() and add it to the where clause. The combination is a stable workspace key:
import { and, eq } from "drizzle-orm";
const { userId, orgId } = await auth();
if (!userId) return new Response(null, { status: 401 });
const rows = await db
.select()
.from(threads)
.where(
orgId
? and(eq(threads.orgId, orgId), eq(threads.userId, userId))
: eq(threads.userId, userId),
);Reload threads after async auth
The first render of <MyProvider> may run before Clerk resolves the user on the client. Drop a small effect inside <AssistantRuntimeProvider> that calls aui.threads().reload() once the user is loaded:
"use client";
import { useAui } from "@assistant-ui/react";
import { useUser } from "@clerk/nextjs";
import { useEffect } from "react";
export function ReloadOnAuth() {
const aui = useAui();
const { isLoaded, isSignedIn, user } = useUser();
useEffect(() => {
if (isLoaded && isSignedIn) aui.threads().reload();
}, [isLoaded, isSignedIn, user?.id]);
return null;
}Mount this component anywhere inside your <AssistantRuntimeProvider> subtree (typically next to the runtime's provider in MyProvider). reload() discards in-flight responses from superseded calls, so it is safe to invoke on every auth transition (sign in, sign out, organization switch).
Run and verify
Sign in. Check:
/api/chatreturns200with a streaming response when authenticated;401when not./api/threadsreturns only the current user's threads.- Switching users (incognito tab, different account) shows a different thread list.
- Switching active organization in Clerk's
<OrganizationSwitcher>triggers a reload (if you wiredorgIdinto the where clause).
Notes
- Cookies are automatic. Clerk's session cookie travels with same-origin fetches; no
credentials: "include"is needed for browser requests to your own API routes. - Server contexts.
auth()from@clerk/nextjs/serverruns in any Next.js server context: server components, route handlers, and server actions. See Clerk'sauth()reference for the supported surfaces. - Cloud users. If you use AssistantCloud, prefer the Clerk JWT template integration over the pattern on this page. Cloud handles workspace assignment and you don't need to build the thread persistence yourself.
- B2B with organizations. Clerk Orgs map cleanly to multi-tenant chat: scope by
orgIdin addition touserId, surface Clerk's<OrganizationSwitcher>(from@clerk/nextjs), and re-fetch threads on org change.