DEV Community

Cover image for Building a Chat Application That Doesn't Forget!
Jesse Hall - codeSTACKr for MongoDB

Posted on • Edited on

3 1 1 1 1

Building a Chat Application That Doesn't Forget!

I just told you… STOP FORGETTING!!

Don’t you hate it when that AI app keeps forgetting the things you’ve told it?!? In this tutorial, we'll solve this problem by building a Next.js chat application with MongoDB as the memory provider for the Vercel AI SDK.

A screenshot of the chat app we'll build in this tutorial.

What you'll learn

By the end of this tutorial, you'll have:

  • A fully functional Next.js app using the App Router.
  • AI chat capabilities with the Vercel AI SDK.
  • Persistent chat history using MongoDB.
  • A clean UI with chat history navigation.
  • Dark/light mode theme support with shadcn/ui components.

The best part? This entire setup can run on MongoDB Atlas's free tier, making it perfect for prototypes, side projects, or learning experiences.

What is the Vercel AI SDK?

The Vercel AI SDK is a library that simplifies building AI-powered user interfaces. It provides a set of tools and abstractions that make it easy to integrate AI models into your applications with minimal boilerplate code. The SDK handles streaming responses, managing state, and provides hooks for React applications that work seamlessly with various AI providers like OpenAI, Anthropic, and others.

What makes the Vercel AI SDK particularly valuable is its developer experience (DX). It offers type-safe APIs, streaming capabilities out of the box, and a unified interface across different AI providers. This means you can switch between models or providers without rewriting your application logic. The SDK also includes built-in support for features like rate limiting, error handling, and now—with memory providers like the one we're building—conversation history persistence.

Prerequisites

Before we jump in, make sure you have:

  • Node.js 18.18 or later installed.
  • A MongoDB Atlas account (the free tier works perfectly).
  • Basic knowledge of Next.js and React.
  • Some familiarity with TypeScript.
  • An OpenAI API key.

Want to cheat? Peek the complete code example. 😅

Project setup

Let's build this application step by step. We'll start with setting up our project and gradually add features until we have a fully functional chat application with persistent memory.

Create a new Next.js project

First, let's create a fresh Next.js project using the create-next-app CLI:

npx create-next-app@latest chat-with-memory
Enter fullscreen mode Exit fullscreen mode

When prompted, select the following options:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Use src directory: Yes
  • Import alias: Yes (default @/*)

Install dependencies

Navigate to your project directory and install the required dependencies:

cd chat-with-memory
npm install ai @ai-sdk/react @ai-sdk/openai mongodb
Enter fullscreen mode Exit fullscreen mode

Set up shadcn/ui

We'll use shadcn/ui for our components. First, initialize shadcn/ui in your project:

npx shadcn@latest init
Enter fullscreen mode Exit fullscreen mode

Now, let's add the specific UI components we'll need:

npx shadcn@latest add button input card avatar scroll-area separator dropdown-menu
Enter fullscreen mode Exit fullscreen mode

Configure environment variables

Create a .env.local file in your project root. This will store our MongoDB and OpenAI configuration:

MONGODB_URI=your_mongodb_uri_here
MONGODB_DB=chat_history_db
OPENAI_API_KEY=your_openai_api_key_here
Enter fullscreen mode Exit fullscreen mode

Make sure to replace the placeholder values with your actual MongoDB URI and OpenAI API key. If you don't have these yet:

  1. Get your MongoDB URI from MongoDB Atlas by creating a free cluster.
  2. Get your OpenAI API key from the OpenAI dashboard.

Now that we have our project set up with all the necessary dependencies and configuration, we can start building the core functionality of our chat application.

Setting up MongoDB connection

Create MongoDB connection utility

First, let's create a utility to handle our MongoDB connections. This is a crucial part of our application as it ensures we maintain efficient database connections, especially in a serverless environment.

Create a new directory called lib in your project's src directory, then create a file called mongodb.ts inside it:

import { MongoClient } from 'mongodb';

if (!process.env.MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable');
}

if (!process.env.MONGODB_DB) {
  throw new Error('Please define the MONGODB_DB environment variable');
}

const uri = process.env.MONGODB_URI;

let client: MongoClient;
let clientPromise: Promise<MongoClient>;

// In development, we use a global variable to maintain the connection
// This prevents creating new connections during hot reloads
if (process.env.NODE_ENV === 'development') {
  const globalWithMongo = global as typeof global & {
    _mongoClientPromise?: Promise<MongoClient>;
  };

  if (!globalWithMongo._mongoClientPromise) {
    client = new MongoClient(uri);
    globalWithMongo._mongoClientPromise = client.connect();
  }
  clientPromise = globalWithMongo._mongoClientPromise;
} else {
  // In production, create a new connection for each instance
  client = new MongoClient(uri);
  clientPromise = client.connect();
}

export default clientPromise;
Enter fullscreen mode Exit fullscreen mode

This connection utility employs a critical pattern for serverless environments. In development mode, we attach the MongoDB client promise to the global object, ensuring that hot reloads (which happen frequently during development) don't create multiple connections. In production, we create a new connection for each server instance, but thanks to Next.js's module caching, this still maintains a single connection per instance rather than per request.

Now that we have our MongoDB connection utility set up, we can start building the data layer for our chat functionality.

Building the chat data layer

Create chat server actions

Let's create server actions to interact with MongoDB for storing and retrieving chat messages. These actions will provide a clean API for our client components to perform database operations.

Create an actions folder inside your app directory, then create a file called chat-actions.ts. Let's build this file step by step:

First, let's add our imports and define our types:

'use server'

import { Message } from 'ai';
import { ObjectId } from 'mongodb';
import clientPromise from '@/lib/mongodb';

export interface ChatMemory {
  _id: ObjectId;
  messages: Message[];
  createdAt: Date;
  updatedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's add the function to create new chats:

/**
 * Creates a new chat in MongoDB
 */
export async function createChat() {
  const client = await clientPromise;
  const db = client.db(process.env.MONGODB_DB);
  const collection = db.collection<ChatMemory>('chats');

  const now = new Date();
  const result = await collection.insertOne({
    _id: new ObjectId(),
    messages: [],
    createdAt: now,
    updatedAt: now
  });

  return result.insertedId.toString();
}
Enter fullscreen mode Exit fullscreen mode

Next, add the function to retrieve a specific chat:

/**
 * Gets a chat by ID from MongoDB
 */
export async function getChat(chatId: string) {
  try {
    const client = await clientPromise;
    const db = client.db(process.env.MONGODB_DB);
    const collection = db.collection<ChatMemory>('chats');

    const objectId = new ObjectId(chatId);
    const chat = await collection.findOne({ _id: objectId });

    if (!chat) {
      return null;
    }

    return {
      id: chat._id.toString(),
      messages: chat.messages,
      createdAt: chat.createdAt,
      updatedAt: chat.updatedAt
    };
  } catch (error) {
    console.error("Error fetching chat:", error);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the function to save messages to a chat:

/**
 * Saves messages to a chat in MongoDB
 */
export async function saveMessages(chatId: string, messages: Message[]) {
  try {
    const client = await clientPromise;
    const db = client.db(process.env.MONGODB_DB);
    const collection = db.collection<ChatMemory>('chats');

    const objectId = new ObjectId(chatId);
    await collection.updateOne(
      { _id: objectId },
      { 
        $set: { 
          messages: messages,
          updatedAt: new Date()
        } 
      },
      { upsert: true }
    );

    return true;
  } catch (error) {
    console.error("Error saving chat:", error);
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's add the function to retrieve all chats:

/**
 * Gets all chats from MongoDB
 */
export async function getAllChats(limit: number = 10) {
  try {
    const client = await clientPromise;
    const db = client.db(process.env.MONGODB_DB);
    const collection = db.collection<ChatMemory>('chats');

    const chats = await collection
      .find({
        // Only return chats that have at least one message
        "messages.0": { $exists: true }
      })
      .sort({ updatedAt: -1 })
      .limit(limit)
      .toArray();

    return chats.map((chat: ChatMemory) => ({
      id: chat._id.toString(),
      messages: chat.messages,
      createdAt: chat.createdAt,
      updatedAt: chat.updatedAt
    }));
  } catch (error) {
    console.error("Error fetching all chats:", error);
    return [];
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, add the function to delete chats:

/**
 * Deletes a chat from MongoDB
 */
export async function deleteChat(chatId: string) {
  try {
    const client = await clientPromise;
    const db = client.db(process.env.MONGODB_DB);
    const collection = db.collection<ChatMemory>('chats');

    const objectId = new ObjectId(chatId);
    const result = await collection.deleteOne({ _id: objectId });
    return result.deletedCount === 1;
  } catch (error) {
    console.error("Error deleting chat:", error);
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

These server actions provide five key operations:

  1. createChat: Creates a new empty chat document with timestamps
  2. getChat: Retrieves a specific chat by ID
  3. saveMessages: Updates a chat with new messages
  4. getAllChats: Fetches a list of chats with at least one message
  5. deleteChat: Removes a chat from the database

Each function follows best practices for error handling and type safety. The 'use server' directive at the top indicates these are server actions in Next.js, meaning they run on the server but can be imported and called directly from client components.

Creating the chat API route

Set up the chat API endpoint

Now, we'll create the API route that handles communication between our chat UI and the AI model while persisting conversations to MongoDB.

Create a new file at app/api/chat/route.ts:

import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { getChat, createChat, saveMessages } from '@/app/actions/chat-actions';

export async function POST(req: Request) {
  const { messages, chatId } = await req.json();

  // Get existing chat or create a new one
  let currentChatId = chatId;
  if (!currentChatId) {
    currentChatId = await createChat();
  }

  // Get existing messages if any
  const existingChat = await getChat(currentChatId);
  const existingMessages = existingChat?.messages || [];

  // Use latest messages from the client but verify with server-side history
  const messagesForModel = messages.length > 0 ? messages : existingMessages;

  // Generate response from the AI model
  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages: messagesForModel,
    temperature: 0.7,
    maxTokens: 1000,
    // When the stream is complete, save all messages including the new response
    onFinish: async (response) => {
      const updatedMessages = [
        ...messagesForModel,
        { role: 'assistant', content: response.text, id: Date.now().toString() }
      ];

      // Save to MongoDB
      await saveMessages(currentChatId, updatedMessages);
    }
  });

  // Return streaming response with chatId in headers
  return result.toDataStreamResponse({
    headers: {
      'Content-Type': 'text/event-stream',
      'X-Chat-Id': currentChatId,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This API route serves as the bridge between our UI and the AI model, handling several important tasks:

  1. Chat session management:
    • Determines if we're continuing an existing chat or starting a new one
    • Creates a MongoDB document for new chats
    • Returns the chat ID to the client
  2. Message history verification:
    • Verifies the client's message history against the server-side history
    • Ensures data consistency even if the client state is outdated
  3. AI response streaming:
    • Uses the Vercel AI SDK's streamText function for streaming responses
    • Creates a better user experience with incremental responses
    • Saves both original messages and AI responses to MongoDB
  4. Custom headers:
    • Returns the chat ID in a custom header
    • Allows the client to update its state for future requests

The route uses a pattern that balances client and server state. While the client maintains the current conversation state for immediate UI updates, the server remains the source of truth for persistent storage.

Building the UI components

Create the main Chat component

Now, let's build the UI for our chat application, starting with the main chat component. In the components folder in your project src directory, add a file called Chat.tsx.

First, let's add our imports and component interface:

'use client'

import { useChat } from "@ai-sdk/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { User, Bot } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { getChat } from "@/app/actions/chat-actions";
import { Message } from "ai";

export default function Chat({ 
  chatId,
  onChatCreated,
  onMessageSent
}: { 
  chatId?: string;
  onChatCreated?: () => void;
  onMessageSent?: () => void;
}) {
Enter fullscreen mode Exit fullscreen mode

Next, let's add our state management and chat history loading:

  const router = useRouter();
  const [initialMessages, setInitialMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [chatKey, setChatKey] = useState(Date.now());

  // Fetch chat history when chatId changes
  useEffect(() => {
    async function loadChatHistory() {
      if (chatId) {
        setIsLoading(true);
        const chat = await getChat(chatId);
        if (chat && chat.messages) {
          setInitialMessages(chat.messages);
        }
        setIsLoading(false);
        // Generate a new key to force the useChat hook to reset
        setChatKey(Date.now());
      } else {
        setInitialMessages([]);
        setChatKey(Date.now());
      }
    }

    loadChatHistory();
  }, [chatId]);
Enter fullscreen mode Exit fullscreen mode

Now, let's set up the chat hook and handle chat creation:

  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    status,
    error
  } = useChat({
    api: "/api/chat",
    id: chatId,
    initialMessages,
    key: chatKey.toString(), // Use a key to force reset when chatId changes
    body: {
      chatId: chatId,
    },
    onResponse: (response) => {
      // If a new chat was created, update the URL
      const newChatId = response.headers.get('X-Chat-Id');
      if (newChatId && !chatId) {
        // Force a refresh to update the sidebar
        router.refresh();
        // Trigger sidebar refresh via callback
        onChatCreated?.();
      }
    },
    onFinish: () => {
      // When a message exchange is complete, refresh the sidebar
      onMessageSent?.();
    }
  });

  // Get title from the first user message (up to 20 characters)
  const title = messages.find(m => m.role === "user")?.content?.substring(0, 20) || "New conversation";
Enter fullscreen mode Exit fullscreen mode

Finally, let's add the component's JSX:

  return (
    <Card className="w-full max-w-2xl mx-auto shadow-md">
      <CardHeader>
        <CardTitle className="text-xl">{chatId ? `${title}...` : "New chat"}</CardTitle>
      </CardHeader>
      <Separator />
      <CardContent className="p-0">
        <ScrollArea className="h-[400px] p-4">
          {isLoading ? (
            <div className="text-center py-8 text-muted-foreground">
              Loading chat history...
            </div>
          ) : messages.length === 0 ? (
            <div className="text-center py-8 text-muted-foreground">
              No messages yet. Start a conversation!
            </div>
          ) : (
            messages.map((m, index) => (
              <div
                key={index}
                className={cn(
                  "flex items-start gap-3 mb-4",
                  m.role === "user" ? "justify-end" : "justify-start"
                )}
              >
                {m.role !== "user" && (
                  <Avatar className="h-8 w-8 bg-secondary">
                    <AvatarFallback className="flex items-center justify-center">
                      <Bot className="h-4 w-4 text-secondary-foreground" />
                    </AvatarFallback>
                  </Avatar>
                )}
                <div
                  className={cn(
                    "rounded-lg px-3 py-2 max-w-[80%]",
                    m.role === "user"
                      ? "bg-primary text-primary-foreground"
                      : "bg-muted text-foreground"
                  )}
                >
                  {m.content}
                </div>
                {m.role === "user" && (
                  <Avatar className="h-8 w-8 bg-secondary">
                    <AvatarFallback className="flex items-center justify-center">
                      <User className="h-4 w-4 text-secondary-foreground" />
                    </AvatarFallback>
                  </Avatar>
                )}
              </div>
            ))
          )}
          {(status === "streaming" || status === "submitted") && (
            <div className="flex items-center gap-2 text-muted-foreground ml-11 mt-2">
              <div className="flex space-x-1">
                <div className="animate-bounce h-2 w-2 rounded-full bg-current"></div>
                <div className="animate-bounce h-2 w-2 rounded-full bg-current delay-100"></div>
                <div className="animate-bounce h-2 w-2 rounded-full bg-current delay-200"></div>
              </div>
              <span>AI is thinking...</span>
            </div>
          )}
        </ScrollArea>
      </CardContent>
      <Separator />
      <CardFooter className="p-4">
        {error && <div className="text-destructive mb-2 text-sm">{error.message}</div>}
        <form onSubmit={handleSubmit} className="flex w-full gap-2">
          <Input
            value={input}
            onChange={handleInputChange}
            placeholder="Type your message..."
            disabled={status === "streaming" || status === "submitted" || isLoading}
            className="flex-1"
          />
          <Button
            type="submit"
            disabled={(status === "streaming" || status === "submitted") || !input.trim() || isLoading}
          >
            Send
          </Button>
        </form>
      </CardFooter>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Chat component integrates several key features:

  1. Dynamic history loading:
    • Loads chat history when the chatId changes
    • Shows loading states during data fetching
  2. useChat hook integration:
    • Handles message state and input management
    • Connects to our API route
    • Manages streaming responses
  3. Chat state management:
    • Uses a unique key to reset the chat when needed
    • Handles new chat creation and updates
  4. UI features:
    • Distinct styling for user and AI messages
    • Loading indicators for AI responses
    • Error handling and display
    • Responsive message layout

The component uses shadcn/ui components to create a polished interface with minimal effort. The ScrollArea component ensures the chat remains scrollable as it fills with messages.

Create the chat sidebar component

Now, let's create the component that displays our chat history. Add a file called ChatSidebar.tsx in the components directory.

First, let's add our imports and types:

'use client'

import { useEffect, useState } from "react";
import { getAllChats, deleteChat } from "@/app/actions/chat-actions";
import { Message } from "ai";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";

interface Chat {
  id: string;
  messages: Message[];
  createdAt: Date;
  updatedAt: Date;
}

export default function ChatSidebar({ 
  activeChatId, 
  onChatSelect,
  refreshTrigger = 0,
  onChatDeleted
}: { 
  activeChatId?: string;
  onChatSelect: (chatId: string) => void;
  refreshTrigger?: number;
  onChatDeleted?: () => void;
}) {
Enter fullscreen mode Exit fullscreen mode

Next, let's add our state management and chat loading logic:

  const [chats, setChats] = useState<Chat[]>([]);
  const [loading, setLoading] = useState(true);

  // Load chats when component mounts or refreshTrigger changes
  useEffect(() => {
    async function loadChats() {
      setLoading(true);
      const chatsList = await getAllChats(20);
      setChats(chatsList);
      setLoading(false);
    }

    loadChats();
  }, [refreshTrigger]);

  // Get title from first user message (up to 20 characters)
  const getChatTitle = (chat: Chat) => {
    const firstUserMessage = chat.messages.find(m => m.role === "user");
    if (firstUserMessage?.content) {
      const title = firstUserMessage.content.substring(0, 20);
      return title.length < firstUserMessage.content.length 
        ? `${title}...` 
        : title;
    }
    return "New conversation";
  };
Enter fullscreen mode Exit fullscreen mode

Now, let's add the chat deletion handler:

  const handleDeleteChat = async (e: React.MouseEvent, chatId: string) => {
    e.stopPropagation();
    await deleteChat(chatId);
    setChats(chats.filter(chat => chat.id !== chatId));

    // If the active chat was deleted, clear the selection
    if (activeChatId === chatId) {
      onChatSelect("");
    }

    // Notify parent component about the deletion
    onChatDeleted?.();
  };
Enter fullscreen mode Exit fullscreen mode

Finally, let's add the component's JSX:

  return (
    <div className="w-64 border-r h-screen bg-muted/5">
      <div className="p-4 border-b">
        <h2 className="font-semibold">Your conversations</h2>
      </div>
      <ScrollArea className="h-[calc(100vh-65px)]">
        <div className="p-2">
          {loading ? (
            <div className="text-center py-8 text-muted-foreground">Loading...</div>
          ) : chats.length === 0 ? (
            <div className="text-center py-8 text-muted-foreground">No conversations yet</div>
          ) : (
            chats.map((chat) => (
              <div 
                key={chat.id}
                onClick={() => onChatSelect(chat.id)}
                className={cn(
                  "flex items-center justify-between p-3 mb-1 rounded-md hover:bg-muted cursor-pointer group",
                  activeChatId === chat.id && "bg-muted"
                )}
              >
                <div className="truncate flex-1">{getChatTitle(chat)}</div>
                <Button
                  variant="ghost"
                  size="icon"
                  className="opacity-0 group-hover:opacity-100 h-7 w-7"
                  onClick={(e) => handleDeleteChat(e, chat.id)}
                >
                  <Trash2 className="h-4 w-4" />
                </Button>
              </div>
            ))
          )}
        </div>
      </ScrollArea>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The ChatSidebar component provides several key features:

  1. Dynamic chat loading:
    • Fetches chats from MongoDB using getAllChats
    • Shows loading and empty states
    • Refreshes when triggered by parent component
  2. Chat selection:
    • Highlights the active chat
    • Notifies parent component when a chat is selected
  3. Chat deletion:
    • Allows users to delete conversations
    • Updates local state and notifies parent
    • Handles active chat deletion gracefully
  4. UI features:
    • Scrollable list of conversations
    • Delete button appears on hover
    • Truncated chat titles from first message
    • Visual feedback for active chat

The sidebar uses the cn utility function from shadcn/ui to conditionally apply Tailwind classes, making the selected chat visually distinct. The delete button only appears when hovering over a chat item, keeping the interface clean while still providing functionality.

Adding theme support

Create theme components

Let's add theme support to our application. First, create a theme-provider.tsx file in the components directory:

"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Enter fullscreen mode Exit fullscreen mode

Next, create a theme toggle component in theme-toggle.tsx:

"use client"

import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export function ThemeToggle() {
  const { setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
Enter fullscreen mode Exit fullscreen mode

Update the root layout

Now, let's update the root layout to include theme support. Update app/layout.tsx:

import type { Metadata } from "next";
import { Inter as FontSans } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/theme-provider";

const fontSans = FontSans({
  subsets: ["latin"],
  variable: "--font-sans",
});

export const metadata: Metadata = {
  title: "AI Chat with MongoDB Memory",
  description: "An AI chat application with persistent memory using MongoDB",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={cn(
        "min-h-screen bg-background font-sans antialiased",
        fontSans.variable
      )}>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create the home page

Finally, let's create the main page that brings everything together. Update app/page.tsx:

"use client";

import { useState, useCallback } from 'react';
import Chat from '@/components/Chat';
import ChatSidebar from '@/components/ChatSidebar';
import { ThemeToggle } from '@/components/theme-toggle';
import { Button } from '@/components/ui/button';
import { PlusCircle } from 'lucide-react';

export default function Home() {
  const [activeChatId, setActiveChatId] = useState<string | undefined>(undefined);
  const [refreshCounter, setRefreshCounter] = useState(0);

  const handleChatSelect = (chatId: string) => {
    if (chatId === "") {
      setActiveChatId(undefined);
    } else {
      setActiveChatId(chatId);
    }
  };

  const handleNewChat = () => {
    setActiveChatId(undefined);
  };

  // Function to trigger a sidebar refresh
  const refreshSidebar = useCallback(() => {
    setRefreshCounter(prev => prev + 1);
  }, []);

  return (
    <div className="flex justify-center bg-background min-h-screen">
      <main className="min-h-screen flex max-w-6xl w-full shadow-sm">
        <ChatSidebar 
          activeChatId={activeChatId} 
          onChatSelect={handleChatSelect} 
          refreshTrigger={refreshCounter}
          onChatDeleted={refreshSidebar}
        />

        <div className="flex-1 p-8 flex flex-col">
          <div className="w-full max-w-2xl mx-auto">
            <div className="flex justify-between items-center mb-8">
              <div>
                <h1 className="text-3xl font-bold tracking-tight mb-2">AI Chat with MongoDB Memory</h1>
                <p className="text-muted-foreground">
                  Your conversations are saved with MongoDB for persistent chat history
                </p>
              </div>
              <div className="flex items-center gap-4">
                <Button 
                  variant="outline" 
                  size="sm" 
                  className="gap-2"
                  onClick={handleNewChat}
                >
                  <PlusCircle className="h-4 w-4" />
                  New chat
                </Button>
                <ThemeToggle />
              </div>
            </div>
            <Chat 
              chatId={activeChatId} 
              onChatCreated={refreshSidebar}
              onMessageSent={refreshSidebar}
            />
          </div>
        </div>
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The home page component serves as the application's layout coordinator:

  1. State management:
    • Maintains the active chat ID
    • Handles chat selection and creation
    • Manages sidebar refresh state
  2. Component integration:
    • Connects ChatSidebar and Chat components
    • Handles communication between components
    • Manages theme toggling
  3. Layout structure:
    • Sidebar on the left
    • Main chat area on the right
    • Header with title and actions

The refresh pattern used here is particularly effective—when the chat component needs to refresh the sidebar (after creating a new chat or adding a message), it calls the refreshSidebar function, which increments the counter. This change triggers a reload of the chat list in the sidebar.

Note: The refresh pattern used in this example is intentionally simple for clarity. In a production application, you might want to use more sophisticated data fetching libraries like SWR, React Query, or Next.js's built-in data fetching mechanisms for more efficient cache invalidation, optimistic updates, and automatic revalidation.

Testing the application

Now that we have all our components in place, let's test the application:

Start your Next.js development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:3000 in your browser, and you should see your chat interface with the history sidebar. Here's what you can try:

  1. Start a new conversation by typing in the input field.
  2. Refresh the page—notice how your chat history persists.
  3. Create another new chat and add some messages.
  4. Use the sidebar to switch between different conversations.
  5. Delete a chat you no longer need.
  6. Toggle between light and dark themes.

When testing your application, pay attention to how the data persists between page reloads and browser sessions. This persistent memory is the core feature we've built, and it's what makes this chat application truly useful compared to ones that forget everything when you refresh.

Next steps

Now that you have a working chat application with persistent memory, you could extend it with features like:

  • User authentication.
  • AI-generated chat titles.
  • Media file support.
  • Export functionality.
  • Conversation branching.
  • Custom AI instructions.
  • Retrival-augmented generation (RAG) support.

Each of these extensions can build upon the foundation we've created, leveraging MongoDB's flexibility to store additional data as needed.

Final thoughts

Building chat apps with memory might seem daunting at first, but MongoDB makes it surprisingly straightforward. The document model is a natural fit for storing conversation threads, and the MongoDB Atlas free tier gives you plenty of room to experiment without worrying about costs.

The combination of Next.js, Vercel AI SDK, and MongoDB creates a powerful foundation for building sophisticated AI chat applications that remember their conversations. The server actions approach in Next.js App Router provides a clean, type-safe way to interact with the database, while the Vercel AI SDK handles the complexities of streaming AI responses.

What makes this architecture particularly valuable is its scalability. As your application grows, MongoDB Atlas can scale with you, from the free tier all the way to enterprise-grade deployments across multiple regions. The document model adapts easily to evolving requirements, allowing you to add new features without major restructuring.

I hope this tutorial helps you build something amazing!


Say Hello! YouTube | Twitter | LinkedIn | Instagram | TikTok

Top comments (0)