ProNextJS
    Loading
    lesson

    Store Chat Data in a Database

    Jack HerringtonJack Herrington

    Our application is doing well. We have set up routes, deployed to production, and established communication with OpenAI. However, we encounter a problem when we hit refresh on the application - it forgets our conversations.

    To fix this, we need to store these conversations in a place accessible both in development and in production. For our purposes, we will use a Vercel database.

    Setting Up a Vercel Database

    Head over to the Vercel application linked to your project. In the dashboard, find the tab labeled "Storage". Here, we're going to create a Postgres database linked with our project.

    Once you are in the Storage section, click on "Create". A terms and conditions window should pop up. Read it thoroughly and when you're ready, click on "Accept".

    As for the database name, you can choose whatever you like. In our case, we will stick to the default name, chat-gpt-app-postgres. After naming, proceed to connect this database to your application.

    postgres database settings

    Remember, in a Next.js application, you are not limited to using only databases from Vercel. The choice to use a Vercel database in this scenario is for the simplicity it offers when having to manage a database both locally and in production.

    Let's start working on linking our Vercel project and Postgres database with our local development environment.

    Install Dependencies

    The first thing we need is the Vercel CLI. In your terminal, run the following command:

    pnpm i -g vercel
    

    Next, we need to install the Postgres flavor associated with Vercel:

    pnpm add @vercel/postgres
    

    Linking Local Development to Production

    Now that we have our database library, we're ready to link our local development instance to our production instance. We'll use the vercel link command for this:

    vercel link
    

    When running the command, the CLI will ask for a few details. First, it wants to confirm your identity. In this case, confirm the link as yourself.

    Next, it will hint the applications available. Since we have only one application chatgpt-app, we'll proceed to select it.

    the vercel link command

    Once this is done, we are effectively linked.

    The vercel link command creates a new .vercel directory inside of the project. This directory houses a link to our project ID, effectively tying our local development instance to our production instance.

    Now we can move on to setting up environment variables for the production instance on Vercel.

    Production Database Configuration

    To fetch the environment variables from our production instance, we'll use the Vercel command line tool. In the terminal, run the following command to pull these variables into a temporary file called env.development:

    vercel env pull env.development
    

    Note that we don't want to overwrite our env.development.local file, which contains the GitHub-related keys!

    After running the above command, the env.development file is created, containing all of the Postgres configuration needed to connect to the database.

    Copy all of the contents from env.development and paste it into the env.development.local file. This way, we'll combine the environment variables with our existing GitHub ID and secret.

    After moving everything over, you can delete the env.development file since it's not longer needed and contains a lot of extra configuration that we don't want cluttering up our project.

    With all of the configuration done, we can start setting up the database.

    Creating the Postgres Schema

    Inside of the src directory, create a new file at db/schema.psql.

    Paste in the following data:

    CREATE TABLE chats (
      id SERIAL PRIMARY KEY,
      user_email VARCHAR(255) NOT NULL,
      name VARCHAR(255) NOT NULL,
      timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    
    CREATE TABLE messages (
      id SERIAL PRIMARY KEY,
      chat_id INT NOT NULL,
      role VARCHAR(32) NOT NULL,
      content TEXT NOT NULL,
      CONSTRAINT fk_chat FOREIGN KEY(chat_id) REFERENCES chats (id)
    );
    

    The above schema defines two tables:

    Within the chats table, we'll have one row per individual chat. The tables will include the following elements:

    • id: Identifies each unique chat.
    • user_email: The email of the user who initialized the chat.
    • name: The name of the chat, based on the first message.
    • timestamp: When the chat was created.

    Each chat will be have many messages.

    For the messages table, there are two main elements included:

    • id: The identifier for the message
    • chat_id: The chat it belongs to.
    • role: Who sent the message.
    • content: The text of the message.

    After defining your schema, save your schema.psql file.

    Applying the Schema

    Next, we need to apply the schema to the server to create these tables.

    In the Vercel dashboard under the Storage tab, find the command to copy into your terminal to create the database tables.

    the Storage tab in the Vercel dashboard

    Once you've copied the command, over in your terminal go into the src/db directory then paste the command. We will add the -f flag to specify we want to run the schema file schema.psql. It should look something like this:

    psql "postgres://default:pass@address-.aws.com/verceldb?sslmode=require" -f schema.psql
    

    After running the command, you'll see CREATE TABLE twice, telling us the tables have been successfully created.

    To interact with your database, run the same command without the -f flag and you will be dropped into an interactive client that allows you to run SQL commands:

    psql "postgres://default:pass@address-.aws.com/verceldb?sslmode=require"
    
    // loads the psql client
    verceldb=> 
    

    Running select * from chats; proves the table is set up and ready to go:

    verceldb=> select * from chats;
    id | user_email | name | timestamp
    ---+------------+------+----------
    (0 rows)
    

    Please note here that even though you can have different database instances for dev and production, you'll be limited to using one database at a time if you're on the free account from Vercel. For the sake of simplicity, let's stick with the single database for both dev and production.

    With the database ready to go, let's define some types to use throughout the application.

    Defining Types

    At the root level, create a new file called src/types.ts. The types we're going to define will essentially mirror the fields in our database.

    The Chat type will contain the same fields as its database counterpart: id, name, userEmail, and timestamp.

    The Message type will be lightweight, and only contain role and content. Other types will extend it, adding their own properties.

    // inside src/types.ts
    
    export interface Chat {
      id: number;
      name: string;
      user_email: string;
      timestamp: Date;
    }
    
    export interface Message {
      role: "user" | "assistant";
      content: string;
    }
    
    export interface StoredMessage extends Message {
      id: number;
      chat_id: number;
    }
    
    export interface ChatWithMessages extends Chat {
      messages: StoredMessage[];
    }
    

    Our groundwork has been laid with the database and types all set up.

    Creating Database Functions

    Our next step is creating functions for common operations like adding a chat or a message.

    Create a new index.ts inside of the db directory. At the top of the file, import sql from Vercel's Postgres package for executing SQL queries, as well as the types we defined earlier:

    // inside db/index.ts
    
    import { sql } from "@vercel/postgres";
    
    import type { Chat, ChatWithMessages, Message} from "../types";
    

    The first function we'll create will be called createChat. This function takes in a user's email address, a name, and messages as parameters. Using the sql package, we'll fire off several SQL queries:

    export async function createChat(
      userEmail: string,
      name: string,
      msgs: Message[]
    ) {
      await sql`INSERT INTO chats (user_email, name) VALUES (${userEmail}, ${name})`;
    
      const { rows: lastInsertId } =
        await sql`  SELECT currval(pg_get_serial_sequence('chats','id'))`;
    
      const chatId = lastInsertId[0].currval;
      for (const msg of msgs) {
        await sql`INSERT INTO messages (chat_id, role, content) VALUES (${chatId}, ${msg.role}, ${msg.content})`;
      }
    
      return chatId;
    }
    

    We also need a function to fetch a chat given its ID. The getChat function will take a chatId and returns a ChatWithMessages that includes all the chat information, including the messages.

    To retrieve a list of all chats, we'll create a helper function named getChats, which simply selects all the chats from the database.

    export async function getChat(
      chatId: number
    ): Promise<ChatWithMessages | null> {
      const { rows: chats } = await sql`SELECT * FROM chats WHERE id = ${chatId}`;
      if (!chats[0]) {
        return null;
      }
      const { rows: messages } =
        await sql`SELECT * FROM messages WHERE chat_id = ${chatId}`;
      return {
        ...chats[0],
        messages: messages.map((msg) => ({
          ...msg,
          role: msg.role as "user" | "assistant",
          content: msg.content,
        })),
      } as ChatWithMessages;
    }
    
    export async function getChats(userEmail: string): Promise<Chat[]> {
      const { rows: chats } =
        await sql`SELECT * FROM chats WHERE user_email = ${userEmail}`;
      return chats as Chat[];
    }
    

    For the homepage, we'll create a getChatsWithMessages function that returns the top three chats along with some of their messages. Then we'll also have a getMessages function that takes a chatId and returns all the messages associated with that chat.

    export async function getChatsWithMessages(
      userEmail: string
    ): Promise<ChatWithMessages[]> {
      const { rows: chats } =
        await sql`SELECT * FROM chats WHERE user_email = ${userEmail} ORDER BY timestamp DESC LIMIT 3`;
    
      for (const chat of chats) {
        const { rows: messages } =
          await sql`SELECT * FROM messages WHERE chat_id = ${chat.id}`;
        chat.messages = messages.map((msg) => ({
          ...msg,
          role: msg.role as "user" | "assistant",
          content: msg.content,
        }));
      }
    
      return chats as ChatWithMessages[];
    }
    
    export async function getMessages(chatId: number) {
      const { rows: messages } =
        await sql`SELECT * FROM messages WHERE chat_id = ${chatId}`;
    
      return messages.map((msg) => ({
        ...msg,
        role: msg.role as "user" | "assistant",
        content: msg.content,
      }));
    }
    

    Vercel's postgres package makes these operations a breeze by setting up our environment variables for us. We can perform SQL queries wherever needed, wait for the result, and voila! We have our data.

    With all of the functions created, let's try them out!

    Updating the getCompletion Server Action

    Over the the getCompletion server action, import createChat and updateChat from @/db:

    // inside db/index.ts
    
    import { createChat, updateChat } from "@/db";
    

    We need these because when we request completions from the server, we are either updating an existing chat or creating a new one.

    Then in order to identify the user making the request, we'll import getServerSession from NextAuth. Remember, we need to use this because we're in a server action, instead of using useSession that we would use in a client component:

    import { getServerSession } from "next-auth";
    

    Now we need to make some updates to the getCompletion function.

    First, we'll add an id to the parameters that corresponds to the chat and will be a number or null.

    export async function getCompletion(
      id: number | null,
      messageHistory: {
        role: "user" | "assistant";
        content: string;
      }[]
    ) { // rest of function as before
    

    Then after the messages variable is created, we'll get the session from getServerSession:

    // inside the getCompletion function:
    
    const session = await getServerSession();
    

    Once we have the session, we need to determine whether we need to call createChat or updateChat by checking for the chatId. If there is no id, we'll call createChat with the session and messages. Otherwise, we'll call updateChat:

    let chatId = id;
    if (!chatId) {
    chatId = await createChat(
        session?.user?.email!,
        messageHistory[0].content,
        messages
    );
    } else {
    await updateChat(chatId, messages);
    }
    
    return {
    messages,
    id: chatId,
    };
    

    Now we need to update the Chat component to match the updated server action.

    Updating the Chat Component

    Inside of components/Chat.tsx, we need to add the chatId as a parameter when calling getCompletion in the onClick handler.

    We could store the chatID in state, but since we don't want a re-render when chatID changes, we'll use a a ref with useRef instead.

    After updating the imports to bring in useRef from React, we'll create a new chatId variable inside the Chat component that starts as null:

    export default function Chat() {
      const [messages, setMessages] = useState<Message[]>([]);
      const [message, setMessage] = useState("");
      const chatId = useRef<number | null>(null);
    ...
    

    Next, we'll update the getCompletion call to use chatId.current as the first parameter, and set the current chatId after the request to getCompletion:

     const onClick = async () => {
        const completions = await getCompletion(chatId.current, [
          ...messages,
          {
            role: "user",
            content: message,
          },
        ]);
        chatId.current = completions.id;
        setMessage("");
        setMessages(completions.messages);
      };
    

    Now that the Chat component has been updated, we can test the app out by typing out a message.

    Testing the Application

    Back in the browser, refresh the application and ask What is 1+2?. The app should display the answer as expected.

    Over in the terminal, we can run a SQL query in the verceldb client to get the messages:

    verceldb=> select * from messages;
    

    The output shows us each of the messages as we saw in the UI:

    id | chat_id | role     | content
    ---+---------+----------+-----------
    3  | 2       | user     | What is 1+2?
    4  | 2       | assistant| 1 + 2 = 3
    (2 rows)
    

    This confirms that our chat application is now successfully interacting with the database both during development and production!

    We are now able to create new chats if no existing chat is present, and update a chat if it's already ongoing. The chatId is being stored efficiently using useRef to prevent unnecessary re-renders, and the database is successfully recording and retrieving chat data both in production and during development.

    Next Steps

    The next concept we are going to explore is parameterized routes.

    Imagine this scenario: you've been interacting in a chat, but you had to step away from your computer. You want to return to that same chat and pick up right where you left off. Parameterized routes make this possible.

    In the next lesson, we'll get this feature set up!

    Transcript

    Things are going great. We've got our application. We've got our routes. It's deploying to production. It's talking to OpenAI and getting results back. But if we hit refresh here, it forgets our conversations, which isn't great. So we want to go and store those conversations somewhere. And we want to have that somewhere

    be accessible both from development and from production. So what we're going to use for that in this scenario is we're going to use a Vercel database. So we'll go back over to our Vercel application. So inside of our project, there's a tab for storage. And we'll create a Postgres database

    associated with this project. So I'll hit Create. And then I'll accept the terms. And then I will just take the default name in this case, chat-gpt-app-postgres. And then I'll connect that to our application. Now, you can use any kind of database in Next.js. You're not limited to just the stuff from Vercel.

    It's just a really easy way, in this case, to have a database both locally and in production that we can use. So now in order to work with this locally, we want to link this Vercel project and this Postgres database to our local development. So how do we do that?

    Well, we use the Vercel CLI for that. So there is a link in the instructions to the Vercel CLI overview. I'm going to use this command to install Vercel globally. With that done, it's now time to set up our database.

    So the first thing we need to do is add our database library in. We're going to use a flavor of Postgres associated with Vercel, so @VercelPostgres. So now we're going to use Vercel link to link our local development instance to our production instance. So I'll set up the local app.

    I do want to do it as me. And then it hints me to the only application that I have, which is the Chatshubt app. And now we're linked. It's created a new .Vercel directory that has a link to that particular project ID. So now in order to go and talk to that particular database

    in production, we need to have the environment variables set up properly. And those environment variables are actually set on that Vercel production instance. So what we want to do is actually just pull them down and then copy them into our local development file. We're going to use that Vercel command again. We're going to use the environment command,

    so @Vercelenv, and then pull to get the environment from production. And we're going to put it in a file env.development. We don't want to overwrite env.development.local because it's got our GitHub ID and secret, which are specific to our local environment. So let's go and do this env.pull.

    So that's created a file env.development. Let's take a look. And it's got all of our Postgres information. Let's go and copy that and then paste it into our development local. And then just get rid of that file. No longer need it. It's got a lot of extra stuff in there we just don't need.

    What we really need are these Postgres credentials. All right, let's hit Save. Now I want to go and initialize our database with our schema. So I'm going to go and create a new directory. So I'll do db and then schema.psql.

    psql stands for PostgreSQL. Schema is the schema. Then I'm going to paste in our schema. So our schema is going to have two tables in it. The chats table, that's going to have the individual chats, one per row. And then we're going to have the messages. We're going to have multiple messages for a single chat.

    So the chats table is going to have each row have an ID. Whoever initialized the chat, that would be the user email. And then the name of the chat, that would be the first message, effectively, and a timestamp. And then the messages, the primary thing there is going to be the role and then also the content.

    Let's hit Save. And now we want to actually execute this schema against the server to create those tables. So we're going to go back to Verisl. And thankfully, it's given us this psql command. So I'm going to copy that.

    And then over in our terminal, I'm going to go to sourcedb. And I'm going to run that command, psql. You're going to need to install psql locally on your machine. There's instructions for that in the instructions. Then we're going to use -f to specify a file and schema to specify that we want to run the schema file.

    So let's hit Return. And it's created those tables. Awesome! Very cool. Now we can actually just get rid of the -f. And we'll get a interactive client that we can actually try. So let's see. Select star from chats. And there we go.

    We got our table all ready to go. Cool! And that's going to be exactly the same database instance in both dev and production. Now, you can have different ones. But on the free account from Verisl, you can only have one database at a time. So just stick with the single database that is in both dev and production.

    OK, now it's time to get good with TypeScript. So I'm going to create some types so we can use those throughout the application. So at the top level here, I'm just going to create types.ts. So these types mainly match what we're going to have in the database. We're going to have chat with all the same fields, ID, name, user email, and timestamp.

    We are going to have a lightweight message. It's going to have just role and content. And then we're going to have the stored message, which is going to add on ID and chat ID. And then we're going to have a chat with messages, which functions chat to go and add on messages. So now we've got our database set up. We've got our types. Now we need to go and create a bunch of functions

    that are going to do things like add a chat and add a message and do all that. So let's go and create that in a new file called index.ts inside of db. From there, we'll bring in SQL from Postgres. That's how we run SQL commands against the database. And then we'll bring in those types that we just defined. So what's the first thing we want to do?

    Well, we want to be able to create a chat. So we'll create a new function called create chat. It's going to have a user email, the name, and the messages as input. And then it's just going to run a bunch of SQL commands using that SQL that we got from the Versal Postgres library. Now, Versal Postgres is really nice. It's taken all those environment variables.

    It has set us up with Versal. And all we need to do is just do SQL wherever you want it, await the response, and we get back our data from our database. So first thing we're going to do is insert a new record into chats. We're going to get whatever ID that came back with. And then we're going to go and insert each message one by one

    into the database for our messages. You'd also want a way to get a chat. So add a new function called get chat that, given a chat ID, will give you back a chat with messages. So that's going to have all the chat information as well

    as all of the messages also in there. We also want to be able to get a list of all the chats. So we have a handy helper function called get chats. Just selects the chats. And then I'm just going to add two more functions, get chats with messages. That's going to be for the home page. It's going to give you the top three chats as well as a couple

    of messages from them. And then also get messages. You give it a chat ID, and it's going to give you back all the messages for that particular chat. Then we also need an update chat function that's going to take a bunch of new messages and then set the messages for that particular chat. So all of this together is going to drive the UI. And all of it, of course, the code for it is in the instructions.

    You don't need to type this out for yourself. OK, so let's actually get to trying this out. So let's go back over into our server action. And we'll bring in create chat and update chat. Because when we ask the server for completion, we are either going to be in the middle of an existing chat, in which case we want to update that chat with a new response. Or we want to go and create a new chat

    and return that ID back to the UI so that it can then go and add on to that chat later. We're also going to want to know who you are. So we're going to bring in GetServerSession from NextAuth. Obviously, server actions run on the server. So in order to get the session, we're not going to use UseSession like we would in a component. We're going to use GetServerSession,

    because that's going to give us the server session. And then right in here, after we get our messages, we're going to want to go in either create a chat or update the chat. So we'll add on GetServerSession. Now we know who you are. We don't know what chat you're talking about. So let's go and add on a parameter here for the ID.

    So we're going to say the chat ID is either a number or null in the case where you are just starting off a conversation. And then down here, we'll say that we don't have a chat ID. If it's null, then we're going to create the chat with the first message from the conversation and with the messages. Otherwise, we're going to update the existing chat with that chat ID.

    And then we'll return the chat ID as ID. All right, looks good. Let's go check it out on chat. Now we see that the chat component is not happy, because GetCompletion is expecting an ID as the first parameter, and we're sending it an array. So we need to, one, send it that chat ID. And second, we need to store that chat ID somewhere.

    So how are we going to store the chat ID? Well, we could use useState. But we don't actually want to re-render the object when the chat ID changes. So let's just use a ref for that. So bring in useRef. And then down here, we'll set up a ref for the chat.

    It's going to be a number or null. And then we'll send that as the first parameter. All right, so that's happy. And then we'll get back a chat ID. So we'll just set that to the current. Yeah, sure, that looks good. OK, so now let's try it out. [TYPING SOUNDS]

    And now we'll just check to see what's in the database by selecting from messages. [TYPING SOUNDS] And there you go. Looks like we've got a good chat going on there. Awesome. So now let's push this to production and see if we also talk to the database in production. So you run a terminal here.

    I'll add and commit my changes. Let's go over and have a look at Versal. All right, now let's try it out. Refresh. I'll ask it what 20 times 30 is. Awesome. Now let's check it out in pSQL locally

    to see if we've added that to the database. All right, this is looking great. Database is working both in development and in production. Next thing we're going to do is take a look at parameterized routes. So what happens if you want to go and reconnect to an existing chat and just keep on adding onto it?

    Well, we'll use a parameterized route, /chats/id, and show the messages for a given chat and allow you to continue on with your conversation. It can make our application much better. You're going to like it. I'll see you in the next session.