DynamoDBAWSBedrockNext.jsClerk | 40 Min Read

How to Build Your Own ChatGPT Clone Using Clerk & AWS Bedrock

Learn to create a ChatGPT clone step by step using Clerk and AWS Bedrock. Build the entire application from scratch with this comprehensive guide.

Over the last few months, AI-powered chat applications like ChatGPT have exploded in popularity and have become some of the largest and most popular applications in use today. And, for a lot of people, they may have an air of mystery and wonder about how they work. So, to help remove that, in today’s post, we’re going to look at building a ChatGPT-inspired application called Chatrock that will be powered by Next.js, AWS Bedrock & DynamoDB, and Clerk.

Below is a sneak peek of the application we’re going to end up with at the end of this tutorial so without further ado, let’s jump in and get building!

Tech Stack Breakdown

But, before we jump in and start writing code for our application, let’s take a quick look at the tech stack we’ll be using to build our app so we can get familiar with the tools we’ll be using to build Chatrock.

Next.js

Next.js has long cemented itself as one of the front runners in the web framework world for JavaScript/TypeScript projects so we’re going to be using that. More specifically we’re going to be using V14 of Next.js which allows us to use some exciting new features like Server Actions and the App Router.

Finally, for our front end, we’re going to be pairing Next.js with the great combination of TailwindCSS and `shadcn/ui` so we can focus on building the functionality of the app and let them handle making it look awesome!

Clerk

Of course, we’ll need some authentication with our application to make sure the queries people ask stay private. And, because authentication is notoriously finicky and potentially problematic to add we’re going to be using Clerk. And, as we’ll see later on when we start building our application and adding the auth in, Clerk makes it super effortless to implement high-quality and effective authentication to Next.js projects.

AWS

The final large piece of the tech stack that we’ll focus on in this section is AWS. Now, we won’t be using all of the services AWS offers (I’m not sure if this is possible) but instead, we’re going to be using two in particular.

The first is AWS DynamoDB which is going to act as our NoSQL database for our project which we’re also going to pair with a Single-Table design architecture.

The second service is what’s going to make our application come alive and give it the AI functionality we need and that service is AWS Bedrock which is their new generative AI service launched in 2023. AWS Bedrock offers multiple models that you can choose from depending on the task you’d like to carry out but for us, we’re going to be making use of Meta’s Llama V2 model, more specifically `meta.llama2-70b-chat-v1`.

Prerequisites

With the overview of our tech stack out of the way, let’s take a quick look at the prerequisites that we’ll need for this project. First of all this tutorial isn’t intended as a Next.js tutorial so if you’re not comfortable working in Next.js or using some of their newer features like the App Router or Server Actions, take a look at their docs, and once you’re comfortable with them, return to this tutorial and carry on building!

Secondly, you’ll need an AWS account to deploy the DynamoDB database we’ll define to as well as give you access to Bedrock. Once you have your AWS account, you’ll need to request access to the specific Bedrock model we’ll be using (`meta.llama2-70b-chat-v1`), this can be quickly done from the AWS Bedrock dashboard.

NOTE: When requesting the model access, make sure to do this from the `us-east-1` region as that’s the region we’ll be using in this tutorial.

While you’re in the AWS dashboard, if you don’t already have an IAM account configured with API keys, you’ll need to create one with these so you can use the DynamoDB and Bedrock SDKs to communicate with AWS from our application. Read how to generate API keys for an IAM user on their docs.

Finally, once you have your AWS account set up and working, you’ll need to configure the AWS CDK on your local machine to allow you to deploy the DynamoDB database we’ll configure in this project. Check out the docs to learn how to do this.

Getting Started

Now, with the tech stack and prerequisites out of the way, we’re ready to get building! The first thing you’ll want to do is clone the `starter-code` branch of the Chatrock repository from GitHub. You’ll then want to install all of the dependencies by running `npm i` in your terminal inside both the root directory and the `infrastructure` directory.

Inside this branch of the project, I’ve already gone ahead and installed the various dependencies we’ll be using for the project. I’ve also configured some boilerplate code for things like TypeScript types we’ll be using as well as some Zod validation schemas that we’ll be using for validating the data we return from DynamoDB as well as validating the form inputs we get from the user. Finally, I’ve also configured some basic UI components we’ll be using from `shadcn/ui`.

Once you have the project cloned, installed, and ready to go, we can move on to the next step which is configuring our AWS SDK clients in the Next.js project as well as adding some basic styling to our application.

Setting Up Our AWS SDK Clients

To configure the AWS SDK clients in our project, create a new file in the root of the project called `config.ts` and then add the below code to it.

./config
1import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
2import { DynamoDB } from "@aws-sdk/client-dynamodb";
3import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";
4
5const awsConfig = {
6 credentials: {
7 accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
8 secretAccessKey: process.env.AWS_SECRET_KEY_VALUE || "",
9 },
10 region: process.env.AWS_API_REGION || "",
11};
12
13export const db = DynamoDBDocument.from(new DynamoDB(awsConfig), {
14 marshallOptions: {
15 convertEmptyValues: true,
16 removeUndefinedValues: true,
17 convertClassInstanceToMap: false,
18 },
19});
20
21export const bedrock = new BedrockRuntimeClient({
22 ...awsConfig,
23 region: "us-east-1",
24});
ts

What this code does is export two clients (`db` and `bedrock`), we can then use these clients inside our Next.js Server Actions to communicate with our database and Bedrock respectively.

Finally, to complete the setup of our AWS clients we need to add some ENVs to our project. In the root of your project create a new file called `.env.local` and add the below values to it, make sure to populate any blank values with ones from your AWS dashboard.

1# AWS
2
3# API Key ID and Value from your IAM account
4AWS_ACCESS_KEY_ID=""
5AWS_SECRET_KEY_VALUE=""
6
7# Set this to the default AWS region your account was configured for on your local machine earlier.
8AWS_API_REGION=""
9
10DB_TABLE_NAME=ChatRockDB

Deploying the Database

With our AWS SDK clients now configured and ready to go, we’re ready to deploy our DynamoDB database for our project. To do this, replace the contents of the file `./infrastructure/lib/chatrock-infrastructure-stack.ts` with the below code.

./infrastructure/lib/chatrock-infrastructure-stack.ts
1import * as cdk from "aws-cdk-lib";
2import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
3import { Construct } from "constructs";
4
5export class ChatRockIntfrastructureStack extends cdk.Stack {
6 constructor(scope: Construct, id: string, props?: cdk.StackProps) {
7 super(scope, id, props);
8
9 // Create a new DynamoDB instance that is set to use on-demand billing and to be removed when the stack is destroyed
10 new Table(this, "ChatRockDB", {
11 partitionKey: { name: "pk", type: AttributeType.STRING },
12 sortKey: { name: "sk", type: AttributeType.STRING },
13 billingMode: BillingMode.PAY_PER_REQUEST,
14 tableName: "ChatRockDB",
15 removalPolicy: cdk.RemovalPolicy.DESTROY,
16 });
17 }
18}
tsx

In this code, we define a new DynamoDB database that is configured to use on-demand billing as well as to be destroyed when the CDK stack is destroyed.

To deploy the database to AWS, run the command `cdk deploy` in your terminal inside the `infrastructure` directory and accept any prompts you’re given. Then once complete your new database called `ChatRockDB` should be deployed and ready to go! With the database sorted, we’re now ready to jump into Next.js and start building our application, let’s do this!

Adding Some Basic Styling

The first thing we’re going to do in our application is take care of some housekeeping and add some basic styles as well as create a new component for the icon we’ll be using. To create the icon component, create a new file at `./components/icon.tsx` and add the below code to it.

./components/icon.tsx
1import { IoMdChatbubbles } from "react-icons/io";
2
3export function Icon() {
4 return (
5 <div className="flex flex-row gap-3 items-center">
6 <div className="bg-stone-50 p-2 rounded-lg shadow-md">
7 <IoMdChatbubbles className="text-4xl text-violet-500" size={24} />
8 </div>
9 </div>
10 );
11}
tsx

With our `Icon` component now created (we’ll need this in a moment for our custom sign-in and sign-up pages). We’re going to quickly turn our attention to the `./app/layout.tsx` file and update it to look like the code below.

./app/layout.tsx
1import type { Metadata } from "next";
2import { Inter as FontSans } from "next/font/google";
3import "./globals.css";
4import { cn } from "@/lib/utils";
5
6export const fontSans = FontSans({
7 subsets: ["latin"],
8 variable: "--font-sans",
9});
10
11export const metadata: Metadata = {
12 title: "Create Next App",
13 description: "Generated by create next app",
14};
15
16export default function RootLayout({
17 children,
18}: Readonly<{
19 children: React.ReactNode;
20}>) {
21 return (
22 <html lang="en">
23 <body
24 className={cn(
25 "flex flex-row min-h-screen bg-background font-sans antialiased text-stone-700 bg-slate-100",
26 fontSans.variable
27 )}
28 >
29 <div className="min-w-max w-full h-screen overflow-y-auto">
30 {children}
31 </div>
32 </body>
33 </html>
34 );
35}
tsx

In this code, we’ve updated the base styles of our entire project to give us a good base to build off in the coming sections when we look at adding in our authentication with Clerk as well as building out the sidebar that will allow users to switch between the various conversations they have as well as sign out. So, let’s take a closer look at both of those next.

Adding Authentication with Clerk

Before we can look at implementing Clerk into our project and using it to protect our application, we first need to configure our application on Clerk’s dashboard. So, if you don’t already have a Clerk account, head over to their dashboard and sign up for one.

Once you’ve signed into your account, we’ll need to create a new application, for our project, we won’t be using any social auth providers and will be keeping it fairly basic with just the “Email” sign-in option they provide. So, for now just select “Email”, give your application a name (”Chatrock”), and then click “Create application”.

Then once you’re on the Clerk dashboard for your application, we need to enable the option for users to have the optional choice to provide us with their name so we can display it on our application’s sidebar when they’re logged in. To enable this option, click on “User & Authentication”, then “Email, Phone, Username” and then scroll down to the “Personal Information” section where you can toggle “Name” on and then press “Apply changes”.

Finally, in the Clerk dashboard, click on “API Keys” on the sidebar and then copy the ENV values shown to you, (make sure that Next.js is selected on the dropdown).

Adding Clerk to Our Application

Now, at this point, we’ve configured our Clerk application on the dashboard and have copied the ENVs that we will need inside our app so we’re now ready to integrate Clerk into our app!

To do this, first, paste the ENVs you copied from the Clerk dashboard into your `.env.local` file from earlier so it now looks something like this.

1# AWS
2
3# API Key ID and Value from your IAM account
4AWS_ACCESS_KEY_ID=""
5AWS_SECRET_KEY_VALUE=""
6
7# Set this to the default AWS region your account was configured for on your local machine earlier.
8AWS_API_REGION=""
9
10DB_TABLE_NAME=ChatRockDB
11
12# CLERK
13
14# Values from your Clerk dashboard
15NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=""
16CLERK_SECRET_KEY=""

With these ENVs added we can now setup Clerk in our application to provide authentication to our users. To do this, we’ll need to wrap our application in the `ClerkProvider` component from the `@clerk/nextjs` package, we can do this by updating our `./app/layout.tsx` file from earlier. Below is what your code should now look like with this added.

./app/layout.tsx
1import type { Metadata } from "next";
2import { Inter as FontSans } from "next/font/google";
3import "./globals.css";
4import { cn } from "@/lib/utils";
5import { ClerkProvider } from "@clerk/nextjs";
6
7export const fontSans = FontSans({
8 subsets: ["latin"],
9 variable: "--font-sans",
10});
11
12export const metadata: Metadata = {
13 title: "Create Next App",
14 description: "Generated by create next app",
15};
16
17export default function RootLayout({
18 children,
19}: Readonly<{
20 children: React.ReactNode;
21}>) {
22 return (
23 <ClerkProvider>
24 <html lang="en">
25 <body
26 className={cn(
27 "flex flex-row min-h-screen bg-background font-sans antialiased text-stone-700 bg-slate-100",
28 fontSans.variable
29 )}
30 >
31 <div className="min-w-max w-full h-screen overflow-y-auto">
32 {children}
33 </div>
34 </body>
35 </html>
36 </ClerkProvider>
37 );
38}
tsx

With our application now wrapped in the `ClerkProvider`, we’ve almost configured our application to have authentication using Clerk. The final thing we need to do is to enforce the authentication in our app by adding a custom `middleware.ts` file to our project. To do, this create a new `middleware.ts` file in the root of your project and add the below code to it.

./middleware.ts
1import { authMiddleware } from "@clerk/nextjs";
2
3export default authMiddleware({});
4
5export const config = {
6 matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
7};
ts

Now, with this custom middleware added, our application enforces authentication and if you were to start your application by running `npm run dev` in the root directory and visit the app on `http://localhost:3000` you would be redirected to the Clerk sign-up page where you could sign up for an account. Then after signing up for an account, you would be redirected back to the home page of our application.

Adding Custom Sign-In and Sign-Up Pages

Now, while I love the ease and simplicity of Clerk’s hosted sign-in and sign-up pages sometimes you will want custom sign-in and sign-up pages to give your user a cohesive experience from start to finish when using your app. So, what we’re going to do now is to create some custom sign-up and sign-in pages that utilize Clerk’s pre-built auth components (`SignIn` and `SignUp`).

The first thing we’ll need to do to create our custom sign-up and sign-in pages is to create the pages in our Next.js application. To do this, create a new file at `./app/sign-up/[[...sign-up]]/page.tsx` and at `./app/sign-in/[[...sign-in]]/page.tsx` and add the below code to them respectively.

./app/sign-up/[[...sign-up]]/page.tsx
1import { Icon } from "@/components/icon";
2import { SignUp } from "@clerk/nextjs";
3import Link from "next/link";
4
5export default function Page() {
6 return (
7 <div className="h-screen flex flex-col items-center justify-center gap-6 min-w-full">
8 <Icon />
9 <h2 className="text-2xl font-semibold text-gray-800">
10 Sign up for a new account
11 </h2>
12 <SignUp
13 appearance={{
14 elements: { footer: "hidden", formButtonPrimary: "bg-violet-700" },
15 }}
16 />
17 <div className="flex flex-row gap-1 text-sm">
18 <p>Already a user?</p>
19 <Link
20 href="/sign-in"
21 className="text-violet-700 underline font-semibold"
22 >
23 Sign in here.
24 </Link>
25 </div>
26 </div>
27 );
28}
tsx
./app/sign-in/[[...sign-in]]/page.tsx
1import { Icon } from "@/components/icon";
2import { SignIn } from "@clerk/nextjs";
3import Link from "next/link";
4
5export default function Page() {
6 return (
7 <div className="h-screen flex flex-col items-center justify-center gap-6 min-w-full">
8 <Icon />
9 <h2 className="text-2xl font-semibold text-gray-800">
10 Sign in to your account
11 </h2>
12 <SignIn
13 appearance={{
14 elements: { footer: "hidden", formButtonPrimary: "bg-violet-700" },
15 }}
16 />
17 <div className="flex flex-row gap-1 text-sm">
18 <p>Not a user?</p>
19 <Link
20 href="/sign-up"
21 className="text-violet-700 underline font-semibold"
22 >
23 Sign up here.
24 </Link>
25 </div>
26 </div>
27 );
28}
tsx

You can see both of these pages are quite similar in structure, they show the `Icon` component we made earlier before then showing a title and rendering their respective pre-built Clerk component (`SignIn` or `SignUp`) from the `@clerk/nextjs` package. At this point, we also perform some minor customizations to the Clerk components to make sure they fit in with our branding a bit better by updating their colors.

Finally, we then render a custom footer to our page which helps users navigate between our sign-up and sign-in pages if they want to change between them at any point.

With our custom pages now built, we have two more things we need to do before our custom authentication pages are ready to go. First of all, we need to update our `.env.local` file from earlier to add in some custom ENVs for the pre-built Clerk components to utilize. Add these ENVs under the ones you added for Clerk earlier on.

1# Other Clerk and AWS ENVs...
2
3NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
4NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
5NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
6NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

Finally, the last thing we need to do is to update our `middleware.ts` file to ensure that the new sign-in/up pages aren’t placed behind our authentication and are made public so anyone can access them. To do this, update your `middleware.ts` file to look like below.

./middleware.ts
1import { authMiddleware } from "@clerk/nextjs";
2
3export default authMiddleware({
4 // Note this line where we've defined our auth pages as public routes to opt-out of authentication
5 publicRoutes: ["/sign-in", "/sign-up"],
6});
7
8export const config = {
9 matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
10};
ts

Our new custom sign-in/up pages are now complete and if you still have your development server running, visit one of the pages and enjoy the new designs we’ve implemented!

NOTE: If you signed up for an account using the Clerk-hosted pages, you won’t be able to sign out of the application at the moment (we’re implementing this in the next section) so if you’d like to check out the new sign in/up pages before then, use private browsing, a different browser, or clear your sessions in the browser’s dev console.

Finishing the Application’s Layout

Now with our authentication setup, let’s take a look at implementing the last piece of our application’s layout, the sidebar. This sidebar will contain two important pieces of functionality, the first is the conversation history of the currently authenticated user which will allow them to switch between different conversations they’ve had. The second is the `UserButton` component from Clerk which will give users an easy way to sign out of the application.

Conversation History

Let’s start by building the conversation history functionality and UI before then tying it together with the `UserButton` component in a new custom `sidebar.tsx` file we’ll create.

To build our conversation history functionality we’ll need to create a couple of Server Actions (`getAllConversations` and `deprecateConversation`), the purpose of both of these should be fairly self-explanatory but we’re going to use the `getAllConversations` action to give us a list of all of the user’s conversations to map over and act as links on the sidebar. Then for each conversation, we’re going to offer the ability to “delete” it using the `deprecateConversation` Server Action.

NOTE: I’ve put delete in quotations because although it’ll appear to the user that we’re deleting the conversation we're actually performing a soft-delete and just marking the item as `DEPRECATED` in the database and only showing them `ACTIVE` ones on the front end.

With the overview of this functionality covered, let’s jump in and get building. So, as mentioned earlier we’re going to be starting by making the new Server Actions, to do this, create a new directory inside the `app` directory called `actions` and then another new one inside that called `db`. Then inside the `db` directory create two new files called `get-all-conversations.ts` and `deprecate-conversation.ts`. Then add the respective code below to each of them.

./app/actions/db/get-all-conversations.ts
1"use server";
2
3import { db } from "@/config";
4import { conversationSchema } from "@/schema";
5import { QueryCommand } from "@aws-sdk/lib-dynamodb";
6import { currentUser } from "@clerk/nextjs";
7
8export const getAllConversations = async (includeDeprecated = false) => {
9 const currentUserData = await currentUser();
10
11 try {
12 // Get the first 100 conversations for the current user from the DB
13 const { Items } = await db.send(
14 new QueryCommand({
15 TableName: process.env.DB_TABLE_NAME,
16 ExpressionAttributeValues: {
17 ":pk": `USER#${currentUserData?.id}`,
18 ":sk": "CONVERSATION#",
19 },
20 KeyConditionExpression: "pk = :pk AND begins_with(sk, :sk)",
21 Limit: 100,
22 })
23 );
24
25 const parsedPrompts = conversationSchema.array().nullish().parse(Items);
26
27 // If the request wants to return deprecated ones, return all data
28 if (includeDeprecated) {
29 return parsedPrompts;
30 }
31
32 // Otherwise filter for just active ones and return them
33 return parsedPrompts?.filter((prompt) => prompt.status === "ACTIVE");
34 } catch (error) {
35 console.error(error);
36 throw new Error("Failed to fetch all conversations");
37 }
38};
ts
./app/actions/db/deprecate-conversation.ts
1"use server";
2
3import { db } from "@/config";
4import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
5import { currentUser } from "@clerk/nextjs";
6
7export const deprecateConversation = async (uuid: string) => {
8 const currentUserData = await currentUser();
9
10 try {
11 // To mark an item as deleted we use "soft deletes" so we update the item status to be "DEPRECATED" and then filter these out when fetching data
12 await db.send(
13 new UpdateCommand({
14 TableName: process.env.DB_TABLE_NAME,
15 Key: {
16 pk: `USER#${currentUserData?.id}`,
17 sk: `CONVERSATION#${uuid}`,
18 },
19 UpdateExpression: "SET #status = :status",
20 ExpressionAttributeNames: {
21 "#status": "status",
22 },
23 ExpressionAttributeValues: {
24 ":status": "DEPRECATED",
25 },
26 })
27 );
28 } catch (error) {
29 console.log(error);
30 throw new Error("Failed to deprecate conversation");
31 }
32};
ts

Conversation History

With our Server Actions now written for our conversation history functionality, let’s build the UI for it. To do this, we’re going to create a new component called `ConversationHistory`, to add this component, create a new file at `./components/conversation-history.tsx` and then add the below code to it.

./components/conversation-history.tsx
1"use client";
2
3import Link from "next/link";
4import { useEffect, useState } from "react";
5import { getAllConversations } from "@/app/actions/db/get-all-conversations";
6import { z } from "zod";
7import { conversationSchema } from "@/schema";
8import { usePathname, useRouter } from "next/navigation";
9import { IoTrashBin } from "react-icons/io5";
10import { deprecateConversation } from "@/app/actions/db/deprecate-conversation";
11
12export default function ConversationHistory() {
13 const pathname = usePathname();
14 const router = useRouter();
15 const [deleting, setDeleting] = useState(false);
16 const [prompts, setPrompts] = useState<
17 z.infer<typeof conversationSchema>[] | null
18 >();
19
20 // When the pathname or the deleting state changes, fetch all of the conversations from the DB and update the state
21 useEffect(() => {
22 const fetchPrompts = async () => {
23 setPrompts(await getAllConversations());
24 setDeleting(false);
25 };
26
27 fetchPrompts();
28 }, [pathname, deleting]);
29
30 return (
31 <div className="flex flex-col gap-2 grow">
32 {prompts?.map((prompt) => {
33 const uuid = prompt.sk.split("#")[1];
34
35 return (
36 <div
37 className="relative flex flex-row justify-start items-center group bg-slate-200 p-2 py-2.5 rounded-sm text-sm hover:bg-slate-100 transiiton-all ease-in-out duration-300"
38 key={prompt.sk}
39 >
40 <Link href={`/${uuid}`}>
41 <span className="w-24 overflow-hidden whitespace-nowrap">
42 {prompt.title}
43 </span>
44 </Link>
45 <button className="absolute right-0 mr-2 opacity-0 group-hover:opacity-100 bg-red-400 p-2 rounded-sm transition-all ease-in-out duration-300">
46 <IoTrashBin
47 onClick={async () => {
48 await deprecateConversation(uuid);
49 setDeleting(true);
50
51 router.push("/");
52 }}
53 />
54 </button>
55 </div>
56 );
57 })}
58 </div>
59 );
60}
tsx

You can see in this code, that we fetch all of the current user’s conversations when the pathname updates or the `deleting` state changes, we then map over their conversations and display a `Link` for each of them that will take the user to the conversation's respective page (we’ll create this later on). Then finally we display a button that appears on hover and when clicked will trigger our `deprecateConversation` functionality and remove the conversation from the view of the user.

With our conversation history functionality now complete we can turn our attention to building the sidebar itself and adding in the `UserButton` component from Clerk that we mentioned earlier. To add this component, create a new file at `./components/sidebar.tsx` and add the below code to it.

./components/sidebar.tsx
1import { UserButton, currentUser } from "@clerk/nextjs";
2import { Icon } from "./icon";
3import Link from "next/link";
4import ConversationHistory from "./conversation-history";
5import { IoAddOutline } from "react-icons/io5";
6
7export async function Sidebar() {
8 const currentUserData = await currentUser();
9
10 // If the user data is falsy, return null. This is needed on the auth pages as the user is authenticated then.
11 if (!currentUserData) {
12 return null;
13 }
14
15 // If the user gave us their name during signup, check here to influence styling on the page and whether we should show the name
16 const hasUserGivenName =
17 currentUserData.firstName && currentUserData.lastName;
18
19 return (
20 <aside className="flex flex-col justify-start min-h-screen w-full py-6 px-8 max-w-60 bg-slate-300 border-r-2 border-r-slate-500 gap-12">
21 <header className="flex flex-row gap-2 justify-between items-center">
22 <Link href="/" className="flex flex-row gap-2 items-center">
23 <Icon />
24 <p className="text-gray-700 font-bold">Chatrock</p>
25 </Link>
26 <Link
27 href="/"
28 className='flex flex-row justify-start items-center group bg-slate-200 p-2 h-max rounded-sm hover:bg-slate-100 transiiton-all ease-in-out duration-300"'
29 >
30 <IoAddOutline />
31 </Link>
32 </header>
33
34 <ConversationHistory />
35 <footer
36 className={`w-full flex flex-row ${
37 !hasUserGivenName && "justify-center"
38 }`}
39 >
40 <UserButton
41 afterSignOutUrl="/sign-in"
42 showName={Boolean(hasUserGivenName)}
43 appearance={{
44 elements: { userButtonBox: "flex-row-reverse" },
45 }}
46 />
47 </footer>
48 </aside>
49 );
50}
tsx

Now, with this component, we do a few things, first of all, we fetch the current user from Clerk using the `currentUser()` function. We then check if there is data for the current user and if not we return `null`. This is important because on non-authenticated pages like the sign-in and sign-up, we will want to return `null` as there is no current user, this will prevent the sidebar from rendering on the page.

Then we check if the current user gave us their name during the signup flow and store that in the `hasUserGivenName` variable which we then use to control the displaying of the user’s name in the `UserButton` component as well as some styling on the sidebar.

Finally, we render out the component which starts by creating a small header section with the Chatrock name and `Icon` component as well as an “Add” (`+`) button that allows users to start a new conversation by being redirected to the home page.

We then render out the `ConversationHistory` component we created a moment ago before finishing the component with a custom footer that contains the `UserButton` component from Clerk that displays the user’s name if they gave it to us and allows them to sign out of the application.

With our `Sidebar` component now created the last thing we need to do is add it to our `layout.tsx` file from earlier so it shows on every authenticated page. To do this, update your `./app/layout.tsx` file to look like the one below.

./app/layout.tsx
1import type { Metadata } from "next";
2import { Inter as FontSans } from "next/font/google";
3import "./globals.css";
4import { cn } from "@/lib/utils";
5import { ClerkProvider } from "@clerk/nextjs";
6import { Sidebar } from "@/components/sidebar";
7
8export const fontSans = FontSans({
9 subsets: ["latin"],
10 variable: "--font-sans",
11});
12
13export const metadata: Metadata = {
14 title: "Create Next App",
15 description: "Generated by create next app",
16};
17
18export default function RootLayout({
19 children,
20}: Readonly<{
21 children: React.ReactNode;
22}>) {
23 return (
24 <ClerkProvider>
25 <html lang="en">
26 <body
27 className={cn(
28 "flex flex-row min-h-screen bg-background font-sans antialiased text-stone-700 bg-slate-100",
29 fontSans.variable
30 )}
31 >
32 <Sidebar />
33 <div className="min-w-max w-full h-screen overflow-y-auto">
34 {children}
35 </div>
36 </body>
37 </html>
38 </ClerkProvider>
39 );
40}
tsx

Building the Home page

At this point, we now have a completed application shell that a user can use to sign in and out of the application freely as well as the functionality to show a user’s conversation history. So, now let’s work on adding in the functionality to allow users to create new conversations which is where the home page comes in.

But before we jump into building the functionality let’s take a moment to explore the flow of how this should work. When the user logs into the application they will be taken to the home page that will show an input field allowing them to ask something to the AI, when they fill in this input and submit it, we’ll create a new conversation in the database, generating a new `UUID` for the conversation. We’ll then return this `UUID` to the front end and redirect the user to that conversation’s specific page where the AI will then be triggered to reply and then the user can reply and so on.

So, for the home page, we need to add in the functionality to allow users to enter a new prompt and then have that input stored in the database before redirecting the user to the newly created conversation’s page (which will `404` for the moment as we’re going to create this in the next section).

Implementing The Functionality

So, now we know what we’re building for the home page, let’s get started building. The first thing we’re going to do is create the new Server Action that’ll allow us to create new conversations in the database. To create this, create a new file in the `./app/actions/db` directory from earlier called `create-conversation.ts` and add the below code.

./app/actions/db/create-conversation.ts
1"use server";
2
3import { db } from "@/config";
4import { conversationSchema } from "@/schema";
5import { IPromptStatus } from "@/types";
6import { PutCommand } from "@aws-sdk/lib-dynamodb";
7import { currentUser } from "@clerk/nextjs";
8import { randomUUID } from "crypto";
9
10export const createConversation = async (prompt: string) => {
11 const currentUserData = await currentUser();
12
13 if (!currentUserData) {
14 throw new Error("User not found");
15 }
16
17 // Generate a randomUUID for the new conversation this will be used for the page UUID
18 const uuid = randomUUID();
19 const conversationUuid = `CONVERSATION#${uuid}`;
20
21 // Build the input for creating the new item in the DB
22 const createBody = {
23 pk: `USER#${currentUserData?.id}`,
24 sk: conversationUuid,
25 uuid,
26 createdAt: new Date().toISOString(),
27 updatedAt: new Date().toISOString(),
28 title: `${prompt.slice(0, 20)}...`,
29 conversation: [
30 {
31 author: `USER#${currentUserData?.id}`,
32 content: prompt,
33 },
34 ],
35 status: IPromptStatus.ACTIVE,
36 };
37
38 try {
39 // Create the item in the DB using the prepared body
40 await db.send(
41 new PutCommand({
42 TableName: process.env.DB_TABLE_NAME,
43 Item: createBody,
44 ReturnValues: "ALL_OLD",
45 })
46 );
47
48 // Return the created data to the frontend
49 return conversationSchema.parse(createBody);
50 } catch (error) {
51 console.error(error);
52 throw new Error("Failed to create conversation");
53 }
54};
ts

Then with our Server Action created, we can move on to building the frontend UI elements to interact with it. The main UI element we need to build is the input that is shown at the bottom of the screen as this is where the user will input their query before it is sent to the Server Action above for processing.

In our application, we’re going to have two forms, one on the home page and one on the individual conversation page. For the most part, these forms are going to be identical with the only differences being in the `onSubmitHandler` function they run when the user submits the form.

So, keeping this in mind and to reduce the duplication of code, we’re going to build a generic version of the input field component called `GenericPromptInput` and then we’re going to build a wrapper of this called `HomePromptInput` that will add in the custom `onSubmitHandler` we need for the home page.

With that explanation out of the way, let’s get started building our `GenericPromptInput` component, to create this add a new file at `./components/prompt-inputs/generic.tsx` and add the below code to it.

./components/prompt-inputs/generic.tsx
1"use client";
2
3import { Button } from "../ui/button";
4import { Input } from "../ui/input";
5import { useForm } from "react-hook-form";
6import { zodResolver } from "@hookform/resolvers/zod";
7import { promptFormSchema } from "@/schema";
8import { PromptFormInputs } from "@/types";
9
10interface IProps {
11 isGenerating?: boolean;
12 onSubmitHandler: (data: PromptFormInputs) => Promise<void>;
13}
14
15export function GenericPromptInput({
16 isGenerating = false,
17 onSubmitHandler,
18}: IProps) {
19 // Create a new useForm instance from react-hook-form to handle our form's state
20 const {
21 register,
22 handleSubmit,
23 reset,
24 formState: { errors },
25 } = useForm<PromptFormInputs>({
26 // Pass the form's values using our Zod schema to ensure the inputs pass validation
27 resolver: zodResolver(promptFormSchema),
28 defaultValues: {
29 prompt: "",
30 },
31 });
32
33 return (
34 <div className="flex flex-col gap-1 w-full items-center max-w-xl">
35 <form
36 className="flex w-full space-x-2"
37 onSubmit={handleSubmit((data) => {
38 // Run the onSubmitHandler passed into the component and then reset the form after submission
39 onSubmitHandler(data);
40 reset();
41 })}
42 >
43 <Input
44 type="text"
45 placeholder="What would you like to ask?"
46 disabled={isGenerating}
47 {...register("prompt", { required: true })}
48 />
49 <Button type="submit" disabled={isGenerating}>
50 Submit
51 </Button>
52 </form>
53 {errors.prompt?.message && (
54 <p className="text-sm text-red-600 self-start">
55 {errors.prompt?.message}
56 </p>
57 )}
58 </div>
59 );
60}
tsx

A fair amount is going on in this file so let’s take a moment to break it down and see what’s going on. The first thing we do in the form is create a new instance of `useForm` from `react-hook-form` which is the package we’re going to be using for handling our form’s state. We pair this with a custom Zod schema that will parse the inputs to the form and will handle all of our validation and error-generating for us.

We then define the UI itself by creating a new `form` element and passing in the `onSubmitHandler` that is passed in as a prop to the component. Then we define our form’s UI which is just a single input and button before rendering any errors out to the page that has been thrown by `react-hook-form` and Zod.

Home Wrapper

With our `GenericPromptInput` taken care of, let’s now write the `HomePromptInput` component which will wrap it and provide the custom `onSubmitHandler` for it. To create this function, add a new file in the `prompt-inputs` directory we created previously called `home.tsx` and add the below code to it.

./components/prompt-inputs/home.tsx
1"use client";
2
3import { createConversation } from "@/app/actions/db/create-conversation";
4import { GenericPromptInput } from "./generic";
5import { PromptFormInputs } from "@/types";
6import { useRouter } from "next/navigation";
7
8export function HomePromptInput() {
9 const router = useRouter();
10
11 // onSubmit hanlder to create a new conversation in the DB based on the user's prompt and then redirect to that conversation's page using the UUID
12 const onSubmitHandler = async (data: PromptFormInputs) => {
13 const { uuid } = await createConversation(data.prompt);
14
15 router.push(`/${uuid}`);
16 };
17
18 return <GenericPromptInput onSubmitHandler={onSubmitHandler} />;
19}
tsx

You can see in comparison this file is much simpler, and all we have in the file is the custom `onSubmitHandler` function which is where we run the logic for creating the new conversation in the database using the Server Action we defined at the top of this section.

Then after the conversation is created in the database, we take the `uuid` returned to us and redirect the user to it, this is then where the logic for the individual conversation page will take over and trigger the AI to generate a response to the prompt the user inputted, we’ll write this logic and functionality in the next section when we look at building the individual conversation page.

Now, with the form input complete for the home page, the last thing we need to do is to update the home page’s UI to display the form as well as some other generic text to inform the user what to do. We can do this by updating the page `./app/page.tsx` with the below code.

./app/page.tsx
1import { HomePromptInput } from "@/components/prompt-inputs/home";
2
3export default function Home() {
4 return (
5 <main className="flex h-full flex-col items-center justify-between p-12">
6 <div className="flex flex-col items-center gap-1 my-auto">
7 <h1 className="font-bold text-2xl">What would you like to ask?</h1>
8 <p>Ask me anything!</p>
9 </div>
10 <HomePromptInput />
11 </main>
12 );
13}
tsx

Again, this is another simple block of code as the primary purpose of this page is to render the input for the user to interact with and then redirect the user to the conversation’s individual page where the majority of the functionality will happen so let’s take a look at building that next. But, as a final recap, your application should now look something like this.

Conversation Page

At this point, we have nearly built the entirety of the application, the last piece of functionality that we need to implement is also the most important, the individual conversation page. On this page, users will be able to read the entire conversation history they’ve had as well as prompt the AI to generate more responses off the back of that conversation. So, let’s start implementing it!

Custom Context

The first thing we need to look at implementing for our conversation page is a custom context provider which will wrap the entire page and facilitate easy sharing of state across all of the components that will be rendered on this page.

To create this context, create a new directory in the root of the project called `context` and then create a new file inside called `conversation-context.tsx` and add the below code to it.

./context/conversation-context.tsx
1// This code was based on an article from Kent C. Dodds (https://kentcdodds.com/blog/how-to-use-react-context-effectively)
2
3import { conversationSchema } from "@/schema";
4import {
5 Dispatch,
6 ReactNode,
7 SetStateAction,
8 createContext,
9 useContext,
10 useState,
11} from "react";
12import { z } from "zod";
13
14const ConversationContext = createContext<
15 | {
16 conversation: z.infer<typeof conversationSchema> | undefined;
17 setConversation: Dispatch<
18 SetStateAction<z.infer<typeof conversationSchema> | undefined>
19 >;
20 isGenerating: boolean;
21 setIsGenerating: Dispatch<SetStateAction<boolean>>;
22 }
23 | undefined
24>(undefined);
25
26function ConversationProvider({ children }: { children: ReactNode }) {
27 const [conversation, setConversation] = useState<
28 z.infer<typeof conversationSchema> | undefined
29 >(undefined);
30 const [isGenerating, setIsGenerating] = useState(false);
31
32 const value = {
33 conversation,
34 setConversation,
35 isGenerating,
36 setIsGenerating,
37 };
38
39 return (
40 <ConversationContext.Provider value={value}>
41 {children}
42 </ConversationContext.Provider>
43 );
44}
45
46function useConversation() {
47 const context = useContext(ConversationContext);
48
49 if (context === undefined) {
50 throw new Error(
51 "useConversation must be used within a ConversationProvider"
52 );
53 }
54
55 return context;
56}
57
58export { ConversationProvider, useConversation };
tsx

In this code, we do a few things, we create a new provider which we can use to wrap our page and then we also define a new custom hook called `useConversation` that will allow us to access the data inside the context.

Inside the context, we have four values, the state container the current conversation’s data and a way to update that state as well as if the AI is currently generating a response or not and a way to update that state.

Finally, if you would like to learn more about this way of writing context in React, I highly recommend checking out this Kent C. Dodds post that this code was based on.

Implementing the Functionality

With our custom context now created, we’re ready to start work on creating the final pieces of functionality for our application. In total we’re going to need to create three new Server Actions and three new components as well so let’s get started.

Conversation Prompt Input

Let’s start by taking a look at some code we’re already familiar with and that’s building the conversation page wrapper of the prompt input component we made in the last section for our home page. As you may recall, I mentioned earlier that the conversation page and the home page will actually share the same input component but with different `onSubmitHandler` functions so let’s go about creating the conversation page’s version now.

However, before we can write the component itself, we first need to create a couple of new Server Actions (`getOneConversation` and `updateConversation`) that we’ll then use in the `onSubmitHandler` in the component. We can create these Server Actions by creating two new files in our `app/actions/db` directory from earlier, `get-one-conversation.ts` and `update-conversation.ts`. Then, once you have these new files add the respective code below.

./app/actions/db/get-one-conversation.ts
1"use server";
2
3import { db } from "@/config";
4import { conversationSchema } from "@/schema";
5import { GetCommand } from "@aws-sdk/lib-dynamodb";
6import { currentUser } from "@clerk/nextjs";
7
8export const getOneConversation = async (uuid: string) => {
9 const currentUserData = await currentUser();
10
11 try {
12 // Fetch the provided conversation UUID for the user from the DB
13 const { Item } = await db.send(
14 new GetCommand({
15 TableName: process.env.DB_TABLE_NAME,
16 Key: {
17 pk: `USER#${currentUserData?.id}`,
18 sk: `CONVERSATION#${uuid}`,
19 },
20 })
21 );
22
23 // Return the data to the frontend, passing it through Zod
24 return conversationSchema.parse(Item);
25 } catch (error) {
26 console.log(error);
27 throw new Error("Failed to fetch conversation");
28 }
29};
ts
./app/actions/db/update-conversation.ts
1"use server";
2
3import { db } from "@/config";
4import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
5import { currentUser } from "@clerk/nextjs";
6import { getOneConversation } from "./get-one-conversation";
7import { conversationSchema } from "@/schema";
8
9export const updateConversation = async (uuid: string, prompt: string) => {
10 const currentUserData = await currentUser();
11
12 if (!currentUserData) {
13 throw new Error("User not found");
14 }
15
16 // Fetch the current conversation from the DB
17 const { conversation } = await getOneConversation(uuid);
18
19 try {
20 // Update the target conversation with the new prompt from the user's form submission
21 const { Attributes } = await db.send(
22 new UpdateCommand({
23 TableName: process.env.DB_TABLE_NAME,
24 Key: {
25 pk: `USER#${currentUserData?.id}`,
26 sk: `CONVERSATION#${uuid}`,
27 },
28 UpdateExpression: "set conversation = :c",
29 ExpressionAttributeValues: {
30 ":c": [
31 ...conversation,
32 {
33 author: `USER#${currentUserData?.id}`,
34 content: prompt,
35 },
36 ],
37 },
38 ReturnValues: "ALL_NEW",
39 })
40 );
41
42 // Return the new conversation with the updated messages to the frontend
43 return conversationSchema.parse(Attributes);
44 } catch (error) {
45 console.error(error);
46 throw new Error("Failed to update conversation");
47 }
48};
ts

With these two new Server Actions added, we can now turn our attention to the UI aspect of the component. To create this UI add a new file in the `./components/prompt-inputs` directory called `conversation.tsx` and add the code below.

./components/prompt-inputs/conversation.tsx
1"use client";
2
3import { updateConversation } from "@/app/actions/db/update-conversation";
4import { GenericPromptInput } from "./generic";
5import { PromptFormInputs } from "@/types";
6import { useConversation } from "@/context/conversation-context";
7
8interface IProps {
9 uuid: string;
10}
11
12export function ConversationPromptInput({ uuid }: IProps) {
13 const { setConversation, isGenerating } = useConversation();
14
15 // onSubmit handler to update the conversation in the DB with the user's new prompt and update the data in context
16 const onSubmitHandler = async (data: PromptFormInputs) => {
17 const updatedConversation = await updateConversation(uuid, data.prompt);
18
19 setConversation(updatedConversation);
20 };
21
22 return (
23 <GenericPromptInput
24 onSubmitHandler={onSubmitHandler}
25 isGenerating={isGenerating}
26 />
27 );
28}
tsx

In this code, you can see we have a fairly basic `onSubmitHandler` which is similar to the one we wrote earlier for the home page but this time we’re updating the conversation instead of creating a new one. The other notable difference is that instead of us redirecting the user to another page we’re instead updating the current conversation stored in the context we created earlier with the updated data following the user’s form submission.

Conversation Display

At this point, we’ve now finished all of the forms for our project and the user is now able to submit new conversations as well as update existing ones with new prompts so now let’s turn our attention to displaying the conversation messages and triggering responses from the AI to make this chatbot come alive!

To do this we’re going to need to create the final Server Action in our project which is the one that is going to communicate with AWS Bedrock to generate new AI responses based on our inputs. To create this, create a new directory in our `./app/actions` directory called `bedrock` and then add a new file inside it called `generate-response.ts` with the below code.

./app/actions/bedrock/generate-response.ts
1"use server";
2
3import { bedrock, db } from "@/config";
4import { getOneConversation } from "../db/get-one-conversation";
5import { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
6import { bedrockResponseSchema, conversationSchema } from "@/schema";
7import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
8import { currentUser } from "@clerk/nextjs";
9
10export const generateResponse = async (uuid: string) => {
11 const currentUserData = await currentUser();
12 const { conversation } = await getOneConversation(uuid);
13
14 // Build the prompt for the AI using the correct syntax
15 const prompt = conversation
16 .map(({ author, content }) => {
17 if (author === "ai") {
18 return `${content}`;
19 } else {
20 // Wrap any user inputs in [INST] blocks
21 return `[INST]${content}[/INST]`;
22 }
23 })
24 .join("");
25
26 // Prepare the input for the AI model
27 const input = {
28 accept: "application/json",
29 contentType: "application/json",
30 modelId: "meta.llama2-70b-chat-v1",
31 body: JSON.stringify({
32 prompt,
33 max_gen_len: 512,
34 temperature: 0.5,
35 top_p: 0.9,
36 }),
37 };
38
39 let generation = "";
40
41 try {
42 // Invoke the Bedrock AI model with the prepared input
43 const bedrockResponse = await bedrock.send(new InvokeModelCommand(input));
44
45 // Parse the response from Bedrock to get the generated text
46 const response = bedrockResponseSchema.parse(
47 JSON.parse(new TextDecoder().decode(bedrockResponse.body))
48 );
49
50 generation = response.generation;
51 } catch (error) {
52 console.error(error);
53 throw new Error("Failed to generate response from Bedrock");
54 }
55
56 try {
57 // Update the conversation in the database adding the updated response to the end of the conversation
58 const { Attributes } = await db.send(
59 new UpdateCommand({
60 TableName: process.env.DB_TABLE_NAME,
61 Key: {
62 pk: `USER#${currentUserData?.id}`,
63 sk: `CONVERSATION#${uuid}`,
64 },
65 UpdateExpression: "set conversation = :c",
66 ExpressionAttributeValues: {
67 ":c": [
68 ...conversation,
69 {
70 author: "ai",
71 content: generation,
72 },
73 ],
74 },
75 ReturnValues: "ALL_NEW",
76 })
77 );
78
79 // Return the updated conversation to the frontend
80 return conversationSchema.parse(Attributes);
81 } catch (error) {
82 console.log(error);
83 throw new Error("Failed to update conversation");
84 }
85};
ts

A fair amount is happening in this Server Action so let’s take a moment to dive into it and see what’s going on. The first thing we do is fetch the current conversation data from the database based on the `uuid` of the conversation that’s provided, we do this using our `getOneConversation` Server Action from earlier.

We then take this data and convert the existing messages inside it into the correct structure that the AI model is expecting which includes wrapping any user messages in `[INST]` blocks. After this, we then prepare the `input` object for our Bedrock request which includes defining the model ID we want to use as well as any parameters we want to use to customize the AI’s response as well as finally including the body we prepared with our messages in.

We then send our request to AWS Bedrock and wait for a response. Once we have the response, we parse it to get the generated reply and then update the conversation in the database to have the latest AI response added to the end. Finally, we then return the updated conversation to the front end so it can be displayed to the user.

With our Server Action now implemented, all we need to do is build the matching frontend UI that will invoke this Server Action and display all of the messages to the user. To do this, create a new component in the `./components` directory called `conversation-display.tsx` and add the below code to it.

./components/conversation-display.tsx
1"use client";
2
3import { generateResponse } from "@/app/actions/bedrock/generate-response";
4import { getOneConversation } from "@/app/actions/db/get-one-conversation";
5import { useConversation } from "@/context/conversation-context";
6import { useEffect } from "react";
7import { MdOutlineComputer, MdOutlinePersonOutline } from "react-icons/md";
8
9interface IProps {
10 uuid: string;
11}
12
13export function ConversationDisplay({ uuid }: IProps) {
14 const { conversation, setConversation, isGenerating, setIsGenerating } =
15 useConversation();
16
17 // When the page loads for the first time, fetch the conversation for the page UUID from the DB and add it to the context
18 useEffect(() => {
19 async function fetchConversation() {
20 const conversation = await getOneConversation(uuid);
21 setConversation(conversation);
22 }
23
24 fetchConversation();
25 }, []);
26
27 // When the conversation is updated run this useEffect
28 useEffect(() => {
29 async function generateAIResponse() {
30 if (isGenerating) return;
31
32 setIsGenerating(true);
33
34 const lastAuthor = conversation?.conversation.at(-1)?.author;
35
36 /**
37 * If the lastAuthor is the 'ai' then we know the user needs to respond so return early and update the context state
38 * If the conversation is falsy, also return and and update the context state
39 */
40 if (!conversation || lastAuthor === "ai") {
41 setIsGenerating(false);
42 return;
43 }
44
45 // Generate a new reply from the AI and update the conversation in the context state below.
46 const generatedReponse = await generateResponse(uuid);
47
48 setConversation(generatedReponse);
49 setIsGenerating(false);
50 }
51
52 generateAIResponse();
53 }, [conversation]);
54
55 return (
56 <div className="flex flex-col items-start gap-12">
57 {conversation?.conversation.map((message, ind) => (
58 <div
59 className="flex flex-row gap-4 items-start"
60 key={`${message.content}-${ind}`}
61 >
62 {message.author === conversation.pk ? (
63 <div className="bg-violet-400 rounded-sm p-2 text-white">
64 <MdOutlinePersonOutline size={20} />
65 </div>
66 ) : (
67 <div className="bg-green-400 rounded-sm p-2 text-white">
68 <MdOutlineComputer />
69 </div>
70 )}
71 <p key={message.content}>{message.content}</p>
72 </div>
73 ))}
74 </div>
75 );
76}
tsx

In this component, we request the current conversation from the database when the page first renders, we then store this data in our context. After the conversation data is stored in context it triggers our second `useEffect` block which is where the request to generate the AI response happens.

In this second `useEffect` block we perform a series of checks to see if an AI response is required or not. For example, we check if the last response was from the AI or the user and if a generation request is already in progress. If a request to the AI is required, we then submit the request using the Server Action we wrote a moment ago before setting the updated conversation data returned to us from the Server Action to our context which would then trigger a re-render and the new message to appear.

While we’re talking about the second `useEffect` block it’s also worth mentioning that this block will also be triggered when a user submits a new message in the conversation because the `onSubmitHandler` we defined before updates the conversation stored in the context which will trigger this `useEffect` block to rerun.

Finally, we then render out all of the messages stored in our context for that conversation by mapping over them and displaying their content as well as an icon to indicate if they came from the AI or the user.

Generating Banner

Finally, with our conversation messages now displaying, we have one last piece of UI we need to create before we can tie it all together. This final piece of UI is a simple component that will display “Generating” on the screen to inform the user that a request to the AI is in progress. This component will also prevent the user from submitting new messages while a request is happening, helping to prevent duplicate requests to the AI.

To create this component, create a new file in the `components` directory called `generating-banner.tsx` and add the below code to it.

./components/generating-banner.tsx
1"use client";
2
3import { useConversation } from "@/context/conversation-context";
4import { AiOutlineLoading } from "react-icons/ai";
5
6export function GeneratingBanner() {
7 const { isGenerating } = useConversation();
8
9 return (
10 <div
11 className={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full h-full flex flex-col items-center justify-center gap-4 bg-gray-100/75 ${
12 isGenerating ? "opacity-100" : "opacity-0 -z-10"
13 }`}
14 >
15 <AiOutlineLoading className="animate-spin text-gray-700" size={56} />
16 <p className="text-2xl font-semibold animate-pulse">Generating...</p>
17 </div>
18 );
19}
tsx

In this component, we use our `useConversation` hook to retrieve the current value of the `isGenerating` state. We then use this value to conditionally style the banner so that it animates onto the page when a request to Bedrock is in progress before hiding again when the request finishes.

Tying it All Together

To finish off the conversation page and tie all of the UI and functionality we’ve created together, let’s create our new conversation page by creating a new directory in the `app` directory called `[uuid]` and then adding a new file to that directory called `page.tsx` with the below code.

./app/[uuid]/page.tsx
1"use client";
2
3import { ConversationPromptInput } from "@/components/prompt-inputs/conversation";
4import { ConversationDisplay } from "@/components/conversation-display";
5import { GeneratingBanner } from "@/components/generating-banner";
6import { ConversationProvider } from "@/context/conversation-context";
7
8interface IPageProps {
9 params: {
10 uuid: string;
11 };
12}
13
14export default function Page({ params: { uuid } }: IPageProps) {
15 return (
16 <main className="relative flex h-full flex-row w-full items-center justify-center p-12 pb-0">
17 <div className="h-full w-full max-w-3xl flex flex-col justify-between items-start gap-12">
18 <ConversationProvider>
19 <GeneratingBanner />
20 <ConversationDisplay uuid={uuid} />
21 <div className="w-full flex justify-center pb-12">
22 <ConversationPromptInput uuid={uuid} />
23 </div>
24 </ConversationProvider>
25 </div>
26 </main>
27 );
28}
tsx

Similar to our home page this page is pretty simple and acts as a holding place for all of the other components we’ve defined in this section. But, there are some important things to note such as the `uuid` parameter we bring in as a prop which will control the active conversation we’re looking at. Another important thing is the `ConversationProvider` we have wrapping all of the components we’ve created to allow them access to the custom context we created.

Testing The Application

Now with all of the code added and all of the functionality implemented, we’re ready to give our new application a shot and test out its AI-generating abilities. To do this, start up your local dev instance if you haven’t already by running `npm run dev` and then head over to `http://localhost:3000` in your browser.

Then once on your application if you don’t have an active session you should be redirected to the sign-in/up page we created. If you do have an account already you can now sign in and be taken to the home page otherwise sign up for a new one using the sign-up page.

Once you’re on the home page you can submit a new prompt using the input field and you should then be taken to the individual page for that conversation where after a second or two the AI should reply to your query. At this point, if you wish you can then submit another prompt and continue the conversation. Finally, if at any point you want to create a new conversation, you can use the `+` icon on the top of the sidebar to create a new standalone conversation via the home page.

At this point if all of the above worked as expected and you have an application that resembles the one shown in the video below then congrats you’ve completed the tutorial and have built your own ChatGPT-inspired chat application, called Chatrock!

Closing Thoughts

In this post, we’ve covered a lot of ground and we learned how to build a ChatGPT-inspired application called Chatrock that uses Next.js, AWS DynamoDB & Bedrock, and Clerk!

I hope you found this post helpful and if you have any questions at all or are stuck in any of the sections of this post, feel free to reach out to me and I’d be happy to help; all of the ways you can contact me are shown on my contact page.

Finally, if you would like to read the entire finished code, you can check out the GitHub repository here and if you would like to learn more about Clerk, make sure to read their excellent documentation here.

Thank you for reading.

NOTE: Once you’re finished with the application, if you want to remove the deployed DyanmoDB table, you can run the `cdk destroy` command from inside the `infrastructure` folder in the project. After accepting any prompts this will remove the database and all of the data inside it.



Content

Latest Blog Posts

Below is my latest blog post and a link to all of my posts.

View All Posts

Content

Latest Video

Here is my YouTube Channel and latest video for your enjoyment.

View All Videos
AWS Bedrock Text and Image Generation Tutorial (AWS SDK)

AWS Bedrock Text and Image Generation Tutorial (AWS SDK)

Contact

Join My Newsletter

Subscribe to my weekly newsletter by filling in the form.

Get my latest content every week and 0 spam!