n4nAI

Authentication for a Vercel AI SDK chatbot with NextAuth

Add authentication to a Vercel AI SDK chatbot using NextAuth.js with step-by-step code examples for protecting API routes and managing session state on the client.

n4n Team3 min read757 words

Audio narration

Coming soon — every post will get a voice note here.

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.

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
  1. Visit http://localhost:3000/chat — you should be redirected to /auth/signin.
  2. Click “Continue with GitHub” — complete the OAuth flow. You’re redirected back to /chat.
  3. Send a message — the streamed response appears. Open the Network tab; the POST to /api/chat returns 200 with a data: stream.
  4. Refresh the page — you remain signed in (session cookie persists). The chat history loads if you implemented Step 6.
  5. Click “Sign out” — you’re signed out and redirected to /. Visiting /chat again 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 state parameter.
  • 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:

  1. Middleware protects /chat and /api/chat at the edge.
  2. Sign-in page handles OAuth and redirects back to the chat.
  3. API route validates the session, streams the model response, and optionally persists conversation history per user.
  4. Client uses useChat with 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.

Tagsvercel-ai-sdknextauthauthenticationchatbot

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building chatbots with vercel ai sdk & next.js posts →