Sessions and MongoDB
This is the point where things actually get interesting. We will be creating sessions to store the context of the conversation and store messages and sessions in MongoDB, so that we can retrieve the conversation history and continue the conversation, just the way it is done in the official ChatGPT app.
Setting up MongoDB
Go to MongoDB and sign up for a free account. Then, create a new cluster and get the connection string from the Connect tab. Once you have the connection string, store the MONGODB_URI in the root directory of your project. Now, we will create the library which will help our application connect to mongodb.
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI!;
const options = {};
/**
* Global is used here to maintain a cached connection across hot reloads in development
*/
declare global {
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
let client: MongoClient;
let clientPromise: Promise<MongoClient>;
if (!process.env.MONGODB_URI) {
throw new Error("Please add MONGODB_URI to .env");
}
if (process.env.NODE_ENV === "development") {
// Reuse client in dev
if (!(global)._mongoClientPromise) {
client = new MongoClient(uri, options);
(global)._mongoClientPromise = client.connect();
}
clientPromise = (global)._mongoClientPromise;
} else {
client = new MongoClient(uri, options);
clientPromise = client.connect();
}
export default clientPromise;Once we have the connection library completed, we will create the chatMessages.ts file which will help us store and retrieve messages from the database.
import clientPromise from "@/lib/mongodb";
import { ObjectId } from "mongodb";
export type Citation = {
filePath: string;
chunkIndex: number;
};
export type ChatMessage = {
_id: ObjectId;
sessionId: ObjectId;
role: "user" | "assistant";
content: string;
citations?: Citation[];
createdAt: Date;
};
/**
* Save a message
*/
export async function saveMessage({
sessionId,
role,
content,
citations,
}: {
sessionId: string;
role: "user" | "assistant";
content: string;
citations?: Citation[];
}) {
try {
const client = await clientPromise;
const db = client.db("secondbrain");
console.log(`[DB] Saving ${role} message for session ${sessionId}...`);
const res = await db.collection("messages").insertOne({
sessionId: new ObjectId(sessionId),
role,
content,
citations,
createdAt: new Date(),
});
console.log(`[DB] Successfully saved message with ID: ${res.insertedId}`);
// bump session updatedAt
const updateRes = await db.collection("sessions").updateOne(
{ _id: new ObjectId(sessionId) },
{ $set: { updatedAt: new Date() } }
);
if (updateRes.matchedCount === 0) {
console.warn(`[DB] Warning: No session found with ID ${sessionId} to update.`);
} else {
console.log(`[DB] Updated session timestamp for ${sessionId}`);
}
} catch (error) {
console.error(`[DB] Error saving message:`, error);
throw error; // Re-throw to ensure the caller knows it failed
}
}
/**
* Load all messages for a session
*/
export async function getMessagesBySession(sessionId: string) {
const client = await clientPromise;
const db = client.db("secondbrain");
return db
.collection<ChatMessage>("messages")
.find({ sessionId: new ObjectId(sessionId) })
.sort({ createdAt: 1 })
.toArray();
}
Create a Session Id Page
This page will be the page containing all the chat conversations for each of the session
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState, useRef, useEffect } from "react";
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import TypingIndicator from "@/app/components/TypingIndicator";
import { Button } from "@/app/components/ui/Button";
import { Input } from "@/app/components/ui/Input";
import { Send, User, Bot, Brain } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { use } from "react";
import { useSearchParams } from "next/navigation";
export default function ChatSessionPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [input, setInput] = useState("");
const { messages, setMessages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: `/api/chat?sessionId=${id}`
}),
body: {
sessionId: id
}
} as any);
const [historyLoaded, setHistoryLoaded] = useState(false);
useEffect(() => {
async function fetchHistory() {
try {
const res = await fetch(`/api/messages/${id}`);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
const mappedMessages = data.map((m: any) => ({
id: m._id,
role: m.role as any,
parts: [{ type: "text" as const, text: m.content }],
}));
setMessages(mappedMessages);
}
setHistoryLoaded(true);
} catch (error) {
console.error("Failed to fetch history:", error);
setHistoryLoaded(true);
}
}
fetchHistory();
}, [id, setMessages]);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || status === "submitted" || status === "streaming") return;
await sendMessage({ text: input, metadata: { sessionId: id } });
setInput("");
};
const isLoading = status === "submitted" || status === "streaming";
const searchParams = useSearchParams();
const firstQuery = searchParams.get("q");
const sentRef = useRef(false);
useEffect(() => {
if (historyLoaded && firstQuery && !sentRef.current && messages.length === 0) {
sentRef.current = true;
sendMessage({
text: firstQuery,
metadata: { sessionId: id }
})
}
}, [historyLoaded, firstQuery, sendMessage, messages.length, id]);
return (
<div className="flex flex-col h-screen bg-[#0a0a0a]">
{/* Header */}
<header className="h-16 border-b border-white/5 flex items-center justify-between px-8 bg-black/50 backdrop-blur-xl shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center border border-white/10">
<Brain className="w-4 h-4 text-blue-400" />
</div>
<div>
<h1 className="text-sm font-semibold text-white">Knowledge Session</h1>
<p className="text-[10px] text-zinc-500 uppercase tracking-widest">ID: {id.slice(-6)}</p>
</div>
</div>
</header>
{/* Messages */}
<main className="flex-1 overflow-y-auto px-4 md:px-0 py-8">
<div className="max-w-3xl mx-auto space-y-8 pb-12">
{messages.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 text-center"
>
<div className="w-16 h-16 rounded-3xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mb-6">
<Brain className="w-8 h-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Deep Knowledge Retrieval</h2>
<p className="text-zinc-500 max-w-sm">
This session is ready. Ask anything about your indexed documents and notes.
</p>
</motion.div>
)}
<AnimatePresence initial={false}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex gap-4 ${m.role === "user" ? "flex-row-reverse" : "flex-row"}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 border ${m.role === "user"
? "bg-blue-600/10 border-blue-500/20 text-blue-400"
: "bg-zinc-800 border-white/10 text-zinc-400"
}`}>
{m.role === "user" ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={`flex flex-col max-w-[85%] ${m.role === "user" ? "items-end" : "items-start"}`}>
<div className={`px-5 py-3 rounded-2xl ${m.role === "user"
? "bg-blue-600 text-white"
: "bg-zinc-900 border border-white/5 text-zinc-200"
}`}>
{m.parts.map((p, i) => (
p.type === "text" ? (
<div key={i} className="prose prose-sm prose-invert max-w-none">
<Markdown
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
className="rounded-xl my-4 text-xs"
{...props}
>
{String(children).replace(/
$/, '')}
</SyntaxHighlighter>
) : (
<code className="bg-white/10 px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
);
}
}}
>
{p.text}
</Markdown>
</div>
) : null
))}
</div>
<div className="mt-1 px-2 text-[10px] text-zinc-600">
{m.role === "user" ? "You" : "Second Brain"}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
{isLoading && (
<div className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-400">
<Bot className="w-4 h-4" />
</div>
<div className="bg-zinc-900/50 border border-white/5 rounded-2xl px-4 py-2">
<TypingIndicator />
</div>
</div>
)}
<div ref={bottomRef} className="h-4" />
</div>
</main>
{/* Input Area */}
<div className="p-6 bg-linear-to-t from-black via-black to-transparent">
<form onSubmit={onSubmit} className="max-w-3xl mx-auto relative group">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search your brain..."
disabled={isLoading}
className="pr-14 h-14 rounded-2xl bg-zinc-900/50 border-white/10 focus:border-blue-500/50 focus:ring-blue-500/20 shadow-2xl transition-all"
/>
<div className="absolute right-2 top-2">
<Button
type="submit"
size="icon"
disabled={isLoading || !input.trim()}
className="h-10 w-10 rounded-xl"
>
<Send className="w-4 h-4" />
</Button>
</div>
</form>
<p className="text-center mt-3 text-[10px] text-zinc-600 tracking-wider">
AI generated responses may be inaccurate. Check citations for verification.
</p>
</div>
</div>
);
}
Create the post api route for sessionId for messages
This API is to get the messages for a specific session.
import { NextRequest } from "next/server";
import { getMessagesBySession } from "@/lib/chatMessages";
export async function GET(_: NextRequest, props: { params: Promise<{ sessionId: string }> }) {
const params = await props.params;
const messages = await getMessagesBySession(params.sessionId);
return Response.json(messages);
}Store the session to MongoDB
This API will help us store the current session to MongoDB
import clientPromise from "@/lib/mongodb";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { firstMessage } = await req.json();
if (!firstMessage?.trim()) {
return new Response("Missing message", {
status: 400
});
}
const client = await clientPromise;
const db = client.db("secondbrain");
const now = new Date();
const res = await db.collection("sessions").insertOne({
title: firstMessage.slice(0, 40),
createdAt: now,
updatedAt: now,
});
return Response.json({
sessionId: res.insertedId.toString(),
});
}
Key implementation details for the Chat UI:
- Vercel AI SDK (
useChat): This hook handles the heavy lifting of chat state management. It tracksmessages, handles theinputfield, and automatically processes the streaming response from the server. - DefaultChatTransport: We use this to explicitly point the
useChathook to our/api/chatendpoint. This provides a clean interface for network communication between the frontend and the AI route. - Rich Text Rendering: The UI uses
react-markdownandreact-syntax-highlighterto render the AI's response. This ensures that code snippets, bold text, and lists are displayed with professional formatting. - Auto-Scrolling: A combination of
useRefanduseEffectensures that as new message chunks arrive, the window automatically scrolls to keep the latest content in view.
Update the Chat API to store the session
import { NextRequest } from "next/server";
import { google } from "@ai-sdk/google";
import { streamText, convertToModelMessages } from "ai";
import { getOrCreateCollection } from "@/lib/chromaClient";
import { saveMessage } from "@/lib/chatMessages";
export const runtime = "nodejs";
export const maxDuration = 40;
const COLLECTION_NAME = "secondbrain";
export async function POST(req: NextRequest) {
const body = await req.json();
const messages = body.messages;
const lastMessage = messages?.[messages.length - 1];
// ✅ sessionId extraction (Root Body > Message Metadata > URL Param)
const sessionId: string | undefined =
body.sessionId ||
lastMessage?.metadata?.sessionId ||
req.nextUrl.searchParams.get("sessionId") ||
undefined;
const userMessage =
lastMessage?.parts?.find((p: any) => p.type === "text")?.text;
if (!sessionId || !messages || !userMessage?.trim()) {
console.error("INVALID PAYLOAD:", JSON.stringify(body, null, 2));
return new Response(JSON.stringify({
error: "Missing sessionId or message",
received: { sessionId: !!sessionId, messages: !!messages, userMessage: !!userMessage }
}), { status: 400 });
}
const query = userMessage.trim();
console.log(`[API] Attempting to save user message for session ${sessionId}`);
await saveMessage({
sessionId,
role: "user",
content: query,
});
/* ---------------- RAG ---------------- */
const collection = await getOrCreateCollection(COLLECTION_NAME);
const ragResults = await collection.query({
queryTexts: [query],
nResults: 5,
include: ["documents", "metadatas"],
});
const docs = ragResults.documents?.[0] ?? [];
const metas = ragResults.metadatas?.[0] ?? [];
const citations = metas.map((m: any) => ({
filePath: m.filePath ?? m.path ?? "unknown",
chunkIndex: m.chunkIndex ?? 0,
}));
const context = docs
.map(
(doc, i) =>
`Source ${i + 1} (${citations[i].filePath}, chunk ${citations[i].chunkIndex}):
${doc}``
)
.join("
");
const systemPrompt = `
You are Adi's personal Second Brain assistant.
Use ONLY the provided Context.
If missing, say:
"I don't have that in my Second Brain yet."
`.trim();
const modelMessages = convertToModelMessages([
{
role: "system",
parts: [{ type: "text", text: systemPrompt }],
},
{
role: "user",
parts: [
{
type: "text",
text: `${query}
---
Context:
${context}`,
},
],
},
]);
const result = streamText({
model: google("gemini-2.5-flash"),
messages: modelMessages,
onFinish: async (event) => {
await saveMessage({
sessionId,
role: "assistant",
content: event.text,
citations,
});
},
});
return result.toUIMessageStreamResponse();
}
Key implementation details for the Chat UI:
- Vercel AI SDK (
useChat): This hook handles the heavy lifting of chat state management. It tracksmessages, handles theinputfield, and automatically processes the streaming response from the server. - DefaultChatTransport: We use this to explicitly point the
useChathook to our/api/chatendpoint. This provides a clean interface for network communication between the frontend and the AI route. - Rich Text Rendering: The UI uses
react-markdownandreact-syntax-highlighterto render the AI's response. This ensures that code snippets, bold text, and lists are displayed with professional formatting. - Auto-Scrolling: A combination of
useRefanduseEffectensures that as new message chunks arrive, the window automatically scrolls to keep the latest content in view.
Add Sidebar to Display the Sessions
Now that we have the data getting saved to database as well, let us create a sidebar component to display the sessions on the left side of the screen.
import { getChatSessions } from "@/lib/chatSessions";
import Link from "next/link";
import { Button } from "./ui/Button";
import { Plus, MessageSquare, Brain} from "lucide-react";
export default async function ChatSidebar() {
const sessions = await getChatSessions();
return (
<aside className="w-72 h-screen border-r border-white/5 bg-black flex flex-col">
{/* Header */}
<div className="p-6">
<Link href="/" className="flex items-center gap-2 mb-8 group">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center group-hover:rotate-12 transition-transform">
<Brain className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg tracking-tight">Second Brain</span>
</Link>
<Link href="/chat">
<Button variant="primary" className="w-full justify-start gap-2 h-11 rounded-xl shadow-blue-500/10">
<Plus className="w-4 h-4" />
New Chat
</Button>
</Link>
</div>
{/* Sessions List */}
<div className="flex-1 overflow-y-auto px-3 space-y-1">
<div className="px-3 mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500">Recent Chats</span>
</div>
{sessions.length === 0 ? (
<div className="px-3 py-4 text-xs text-zinc-500 italic">
No recent sessions
</div>
) : (
sessions.map((s: any) => (
<Link
key={s._id.toString()}
href={`/chat/${s._id.toString()}`}
className="group flex items-center gap-3 px-3 py-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-all"
>
<MessageSquare className="w-4 h-4 shrink-0 opacity-50 group-hover:opacity-100" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{s.title || "Untitled Chat"}</div>
<div className="text-[10px] opacity-40 mt-0.5">
{new Date(s.updatedAt).toLocaleDateString([], { month: 'short', day: 'numeric' })}
</div>
</div>
</Link>
))
)}
</div>
</aside>
);
}
Creating chatSessions Library
import { ObjectId } from "mongodb"
import clientPromise from "./mongodb";
export type ChatSession = {
_id: ObjectId,
title: string;
createdAt: Date;
updatedAt: Date;
};
export async function getChatSessions() {
const client = await clientPromise;
const db = client.db("secondbrain");
return db.collection<ChatSession>("sessions")
.find({})
.sort({ updatedAt: -1 })
.toArray();
}
export async function createChatSession() {
const client = await clientPromise;
const db = client.db("secondbrain");
const now = new Date();
const res = await db.collection("sessions").insertOne({
title: "New Chat",
createdAt: now,
updatedAt: now,
});
return {
_id: res.insertedId,
title: "New Chat",
};
}
export async function getChatSessionById(id: string) {
const client = await clientPromise;
const db = client.db("secondbrain");
return db.collection("sessions").findOne({
_id: new ObjectId(id),
});
}
Next Steps
In the next section, we’ll:
Add Input and Output Guardrails
Add input and output guardrails to the chat API
If you want to know more about this, do checkout our video guide:
