Adding authentication to a Vercel AI SDK chatbot with NextAuth.js means protecting your chat API route, gating the UI behind a sign-in flow, and threading the user’s identity through to any downstream logic — whether that’s per-user conversation history, rate limiting, or model routing. This guide walks through a complete implementation using NextAuth v5 (Auth.js), the App Router, and the useChat hook. You’ll end up with a chatbot that only authenticated users can access, with their session available in every request.
Step 1: Install dependencies and configure environment
Start with a Next.js 14+ project using the App Router. Install the Auth.js packages and a provider — here we use GitHub, but the pattern is identical for Google, credentials, or any OAuth provider.
npm i next-auth@beta @auth/prisma-adapter prisma
npm i -D prisma
Initialize Prisma and add the Auth.js schema:
npx prisma init
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
chats Chat[] // optional: link conversations to users
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// Optional: persist conversations per user
model Chat {
id String @id @default(cuid())
userId String
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Message {
id String @id @default(cuid())
chatId String
role String // 'user' | 'assistant' | 'system'
content String @db.Text
createdAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
}
Run npx prisma migrate dev --name init and add the required environment variables:
# .env
DATABASE_URL="postgresql://user:pass@localhost:5432/chatbot?schema=public"
AUTH_SECRET="generate-with-openssl-rand-base64-32"
AUTH_GITHUB_ID="your-github-oauth-client-id"
AUTH_GITHUB_SECRET="your-github-oauth-client-secret"
Step 2: Create the NextAuth configuration
Create the auth configuration file. In NextAuth v5, this lives at auth.ts (or auth.config.ts) and exports the handlers and middleware.
// auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [GitHub],
callbacks: {
// Attach user id to the session for easy access in API routes
async session({ session, user }) {
if (session.user) {
session.user.id = user.id
}
return session
},
},
// Optional: customize pages
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
})
Create the route handlers for the App Router:
// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers
Add the middleware to protect routes automatically:
// middleware.ts
export { auth as middleware } from "@/auth"
export const config = {
matcher: ["/chat/:path*", "/api/chat/:path*"],
}
This middleware runs before any request to /chat or /api/chat and redirects unauthenticated users to the sign-in page.
Step 3: Build the protected chat API route
The Vercel AI SDK’s streamText integrates cleanly with Next.js route handlers. Wrap the handler with auth() to get the session, then enforce authorization before calling the model.
// app/api/chat/route.ts
import { auth } from "@/auth"
import { streamText } from "ai"
import { openai } from "@ai-sdk/openai"
export const maxDuration = 30
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 })
}
const { messages } = await req.json()
// Optional: enforce per-user rate limits, fetch user preferences, etc.
// const user = await prisma.user.findUnique({ where: { id: session.user.id } })
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
system: "You are a helpful assistant.",
// Attach user metadata to the request for logging or routing
// n4n.ai honors client routing directives and forwards provider cache-control hints
// Example: route to a specific model per user tier
// model: user?.tier === "pro" ? openai("gpt-4o") : openai("gpt-4o-mini"),
})
return result.toDataStreamResponse()
}
Key points: auth() returns the session when the middleware has already validated the request, so the check is fast. The user ID is available for any downstream logic — saving conversations, applying quotas, or routing to different models.
Step 4: Create the sign-in page
The middleware redirects unauthenticated users to /auth/signin. Build a minimal page that calls signIn.
// app/auth/signin/page.tsx
"use client"
import { signIn } from "next-auth/react"
export default function SignInPage() {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: "1rem" }}>
<h1>Sign in to chat</h1>
<button
onClick={() => signIn("github", { callbackUrl: "/chat" })}
style={{ padding: "0.75rem 1.5rem", fontSize: "1rem" }}
>
Continue with GitHub
</button>
</div>
)
}
Add a sign-out button somewhere in your layout or chat page:
// components/SignOutButton.tsx
"use client"
import { signOut, useSession } from "next-auth/react"
export function SignOutButton() {
const { data: session } = useSession()
if (!session) return null
return (
<button
onClick={() => signOut({ callbackUrl: "/" })}
style={{ marginLeft: "auto", padding: "0.5rem 1rem" }}
>
Sign out
</button>
)
}
Step 5: Build the chat interface with useChat
The Vercel AI SDK’s useChat hook handles streaming, state, and retries. Wrap it in a client component and pass the user’s session for context if needed.
// app/chat/page.tsx
"use client"
import { useChat } from "ai/react"
import { SignOutButton } from "@/components/SignOutButton"
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
// Optional: send user metadata with each request
// body: { userId: session.user.id },
onError: (err) => {
console.error("Chat error:", err)
alert("Failed to send message. Please try again.")
},
})
return (
<div style={{ maxWidth: "720px", margin: "0 auto", padding: "2rem", display: "flex", flexDirection: "column", height: "100vh" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem" }}>
<h1>Chat</h1>
<SignOutButton />
</header>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: "1rem", marginBottom: "1.5rem" }}>
{messages.map((m) => (
<div
key={m.id}
style={{
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
maxWidth: "80%",
padding: "0.75rem 1rem",
borderRadius: "1rem",
backgroundColor: m.role === "user" ? "#2563eb" : "#1f2937",
color: "white",
}}
>
{m.content}
</div>
))}
{isLoading && (
<div style={{ alignSelf: "flex-start", padding: "0.75rem 1rem", color: "#9ca3af" }}>
Thinking…
</div>
)}
{error && (
<div style={{ color: "#ef4444", padding: "0.5rem" }}>
Error: {error.message}
</div>
)}
</div>
<form onSubmit={handleSubmit} style={{ display: "flex", gap: "0.5rem" }}>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message…"
disabled={isLoading}
style={{ flex: 1, padding: "0.75rem 1rem", borderRadius: "0.5rem", border: "1px solid #374151", backgroundColor: "#111827", color: "white" }}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
style={{ padding: "0.75rem 1.5rem", borderRadius: "0.5rem", backgroundColor: "#2563eb", color: "white", border: "none", cursor: isLoading ? "not-allowed" : "pointer", opacity: isLoading ? 0.6 : 1 }}
>
Send
</button>
</form>
</div>
)
}
The useChat hook POSTs to /api/chat automatically. Because the middleware protects that route, unauthenticated requests never reach your model — they’re redirected at the edge.
Step 6: Persist conversations per user (optional but recommended)
A production chatbot needs history. Extend the API route to create or resume a chat record tied to the user.
// app/api/chat/route.ts (extended)
import { auth } from "@/auth"
import { streamText } from "ai"
import { openai } from "@ai-sdk/openai"
import { prisma } from "@/lib/prisma"
export const maxDuration = 30
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 })
}
const { messages, chatId } = await req.json()
let chat = chatId
? await prisma.chat.findFirst({ where: { id: chatId, userId: session.user.id } })
: null
if (!chat) {
chat = await prisma.chat.create({
data: {
userId: session.user.id,
title: messages[0]?.content?.slice(0, 50) ?? "New chat",
messages: {
create: messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
},
},
})
} else {
// Append new messages
await prisma.message.createMany({
data: messages.slice(chat.messages.length).map((m: { role: string; content: string }) => ({
chatId: chat.id,
role: m.role,
content: m.content,
})),
})
}
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
system: "You are a helpful assistant.",
onFinish: async ({ responseMessages }) => {
// Save assistant response
await prisma.message.createMany({
data: responseMessages.map((m) => ({
chatId: chat.id,
role: m.role,
content: m.content,
})),
})
// Update chat timestamp
await prisma.chat.update({ where: { id: chat.id }, data: { updatedAt: new Date() } })
},
})
// Return chatId so the client can resume this conversation
return result.toDataStreamResponse({
headers: { "x-chat-id": chat.id },
})
}
Update the client to read the x-chat-id header and include it on subsequent requests:
// app/chat/page.tsx (updated useChat call)
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: "/api/chat",
onFinish: (message, { response }) => {
const chatId = response.headers.get("x-chat-id")
if (chatId) {
// Store in localStorage or React state for next request
localStorage.setItem("currentChatId", chatId)
}
},
body: {
chatId: localStorage.getItem("currentChatId") || undefined,
},
})
Step 7: Verify the implementation
Run the dev server and test the full flow:
npm run dev
- Visit
http://localhost:3000/chat— you should be redirected to/auth/signin. - Click “Continue with GitHub” — complete the OAuth flow. You’re redirected back to
/chat. - Send a message — the streamed response appears. Open the Network tab; the POST to
/api/chatreturns 200 with adata:stream. - Refresh the page — you remain signed in (session cookie persists). The chat history loads if you implemented Step 6.
- Click “Sign out” — you’re signed out and redirected to
/. Visiting/chatagain redirects to sign-in.
Verify session in API route
Add a temporary log to confirm the user ID reaches your handler:
// app/api/chat/route.ts (temporary)
console.log("Chat request from user:", session.user.id)
Check the server console when you send a message.
Verify middleware protection
In a new incognito window, hit http://localhost:3000/api/chat directly with curl:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"hi"}]}'
You should get a 307 redirect to the sign-in page (or 401 if you remove the middleware and rely solely on the auth() check).
Production considerations
- Session strategy: The default JWT strategy works for most cases. For server-side session revocation, switch to database sessions in
auth.ts:session: { strategy: "database" }. - Rate limiting: Apply per-user limits in the API route using the
session.user.id. Consider Upstash Redis or a similar edge-compatible store. - Model routing: If you route requests through a gateway (like n4n.ai), pass the user tier in the request body and select the model accordingly.
- CSRF: NextAuth handles CSRF automatically for credential providers. OAuth flows are inherently CSRF-safe via the
stateparameter. - Edge runtime: The chat route can run on the edge (
export const runtime = "edge") if your ORM and model provider support it. Prisma requires the Data Proxy or a driver adapter for edge.
Summary
You now have a Vercel AI SDK chatbot with NextAuth authentication wired end to end:
- Middleware protects
/chatand/api/chatat the edge. - Sign-in page handles OAuth and redirects back to the chat.
- API route validates the session, streams the model response, and optionally persists conversation history per user.
- Client uses
useChatwith automatic streaming and error handling.
The pattern scales: add more providers, swap the database adapter, or layer in feature flags per user — all without changing the core auth flow.