Welcome to part 5 of the tutorial! In this part, we’ll be adding a chat feature to the Blockbuster page. We’ve been preparing for this for so long, and now we’ll finally get to create something cool (and understand what we’re doing in the process!).
We’ll continue to work on our route.tsx file in the api/blockbuster
folder:
π app π api π blockbuster π route.tsx π οΈ We'll be working on this file π blockbuster_chat π page.tsx π counter π page.tsx π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
The route we created last time was cool, but we need more functionality, we need more power!
Creating a POST route
We need a POST route in the API so that it allows the page on the client side to send data to the server. This will allow us to send a movie name, a movie character, and a user question to the server, and use this data to do some cool stuff on the backend before sending a response back to the client.
The first thing we’ll do inside the route.tsx
file is move the openAIConfig
and openai
variables outside of the GET
function, so we can reuse them in our new POST
function as well:
import { NextRequest, NextResponse } from "next/server"; import OpenAI from "openai"; // move these two outside the function for reuse const openAIConfig = { apiKey: process.env.OPENAI_API_KEY }; const openai = new OpenAI(openAIConfig); export async function GET(request: NextRequest) { const systemMessage ...etc... }
Now we’ll add a second function to the file, this time for handling POST requests. You’ll see that this is very similar to what we’ve done so far:
export async function POST(request: NextRequest) { const { movieName, movieCharacter, question } = await request.json(); const systemMessage = `You are helpful and provide good information but you are ${movieCharacter} from ${movieName}. You will stay in character as ${movieCharacter} no matter what. Make sure you find some way to relate your responses to ${movieCharacter}'s personality or the movie ${movieName} at least once every response.`; const answer = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: systemMessage }, { role: "user", content: question }, ] }); return NextResponse.json({ answer: answer.choices[0].message.content }); }
Let’s cover the differences as most of this is familiar by now. At the start, we destructure the movieName
, movieCharacter
, and question
from the JSON body of the request
by awaiting request.json()
. The request
object is the data that gets sent from the client to the server so this will have our three variables. Of course we’ll have to take care of actually sending this data from the front end later on so we can receive it here.
The systemMessage
instructs ChatGPT to take on the persona of the specified movie character by concatenating the movieCharacter
and movieName
variables inside the string using the ${}
syntax to insert the variables.
The rest is exactly the same as before except for this time the question
of the user is sent instead of the hardcoded string question we had before.
If you have Postman or a similar tool installed and you are familiar with how it works you can test this route by sending a POST request to http://localhost:3000/api/blockbuster
with a JSON body in the POST request like this:
You can see we got the response as expected. If you don’t have Postman installed or you’re not familiar with it, don’t worry, we’ll be testing this in the browser in a moment anyway. You can install it if you want to play around with it more, but we’ll save time by not covering it in detail here and you can just skip this step without missing out on anything important.
Creating the frontend
Now that we have a working backend API endpoint for our chat feature, it’s time to implement the front end. We’ll be doing this in the blockbuster_chat/page.tsx
file:
π app π api π blockbuster π route.tsx π blockbuster_chat π page.tsx π οΈ We'll be working on this file π counter π page.tsx π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
Open up the page.tsx
file and we’re going to start from scratch one last time. I’ll basically delete everything we have and start fresh. Let’s start with the imports:
"use client"; import React, { useState, ChangeEvent, FormEvent } from "react";
As we need local-state for the user here, this will still be a client-side component, so we use the "use client"
declaration We’re importing React
, useState
, ChangeEvent
, and FormEvent
from the react
package. So what are these new imports ChangeEvent
and FormEvent
? These are types that are used for TypeScript to help handle events related to forms and input fields. They are not really that important and you’ll see how we use them later.
We’re going to need to take input from the user, so we’ll be adding several input fields to our page. We’ll define a reusable input field component to reduce the duplication a bit. Before we write a new component named InputField
, we’ll have to define the interface for the props or input arguments that this component will receive.
interface InputFieldProps { label: string; value: string; onChange: (event: ChangeEvent<HTMLInputElement>) => void; disabled?: boolean; }
This will let TypeScript know what all the input arguments to our InputField
component will be. We have a label
which is a string and will hold the label alerting the user what to type into this input box. Second, we have a value
which is a string that will hold the current value, or whatever has been typed into the input box so far at this moment.
The third argument is an onChange
function which will be called whenever the user types something into the input box. This function will receive an event object of type ChangeEvent<HTMLInputElement>
. This is a TypeScript type that represents an event that is triggered when the user types something into an input field, don’t worry too much about the TypeScript types here.
All we are defining here is that our InputField component will receive a function under the name onChange
that will be called whenever the user types something into the input field. This function is going to receive an event
(which is just an object that contains information about the event that was triggered, in this case, what the user typed in the box). This function then does something with this event and will return void
in the end which simply means it doesn’t return anything.
Finally, we have an optional argument disabled
which is a boolean that will be used to disable the input field if it is set to true
. It is defined as optional in TypeScript by adding the ?
there at the end like disabled?
This is optional because we don’t always want to disable the input field, only sometimes.
All this object defines is a structure. The exact structure or shape of the arguments that our not-yet-created InputField
component will receive. If this TypeScript stuff confuses you, don’t worry, it can honestly be a bit weird and much at first but you’ll get used to it. Just do your best to understand the general idea.
Creating the InputField component
So now let’s define the actual InputField
component that we’ll then reuse later on to create several inputs for the user to type into:
const InputField: React.FC<InputFieldProps> = ({ label, value, onChange, disabled = false, }) => { return ( <div className="mb-4 w-full"> <label className="block mb-2 text-gray-700">{label}:</label> <input type="text" value={value} onChange={onChange} disabled={disabled} className="w-full p-2 border border-gray-300 rounded bg-white" /> </div> ); };
We define a new const InputField
which is of type React.FC
where FC means functional component. This component will receive the props defined in the InputFieldProps
interface to complete the type definition as React.FC<InputFieldProps>
. We can see the input arguments as expected: label
, value
, onChange
, and disabled
with a default value of false
.
The function body then simply has a return
statement that returns a div
element with a label
and an input
element inside. This is a basic HTML structure to create a block with a label and input box. The label
is the text that will be displayed to the user to tell them what to type into the input box. The input
element is the actual input box where the user can type in their text.
We display the {label}
text inside the label
element. The HTML input
is of type text
and has a value
attribute that is set to the value
prop that was passed to the component, this will allow us to keep the value up to date with the state of the component.
The onChange
attribute is set to the onChange
prop that was passed to the component, so this is what is going to call the onChange function and pass in the event
with the changes. The disabled
parameter is simply set to either true
or false
depending on the value of the disabled
prop that was passed to the component.
As for all the classNames
, they are just Tailwind classes, and I don’t want to go too deeply into them here, but they are just for styling purposes. Like mb-4
for margin-bottom4, w-full
for full width, block
for block display, mb-2
for margin-bottom, text-gray-700
for text color, p-2
for padding, border
for border, rounded
for rounded corners, bg-white
for background color, etc.
As styling/CSS is a large topic on its own, we won’t go into it in too much detail here in favor of focussing a bit more on the code parts and how it all works together.
If any of this seems confusing, hang in there, it will probably make more sense as we continue and write out the whole thing. Just look it over again after we’re done.
Creating the main BlockbusterChat component
Now for the big one, the main component that will hold everything together, the BlockbusterChat
component. I’ll write the thing here in blocks and break it up with explanation comments every couple of lines. It may be best just to read over this first as this is all a single function, and then get to copying it after we go through the explanation as I’ll show the whole thing together again.
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [answer, setAnswer] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
We start our component and define a whole list of state objects. We have movieName
, movieCharacter
, question
, and answer
which are all strings and will hold the values of the movie name, movie character, question, and answer respectively. We also have isSubmitted
, which will keep track of when the question has been submitted (button has been clicked), and isLoading
which will be set to true to indicate we are currently waiting for the API response to come back.
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true); const response = await fetch("/api/blockbuster", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question }), }); const data: { answer: string } = await response.json(); setAnswer(data.answer); setIsLoading(false); };
We define a handleSubmit
function that will be called when the user submits the form. This is an async function that will receive an event of type FormEvent<HTMLFormElement>
as input, which is simply the object that our form with several input boxes will send to the function containing the data that was typed into the input boxes.
We call event.preventDefault()
to prevent the default behavior of the form which is to reload the page when the submit button is clicked and is highly undesirable. We set both isSubmitted
and isLoading
to true
to indicate that the form has been submitted and we are currently waiting for the API response.
We make a fetch
request to our own API, almost the same as the ones we’ve done before. This time we changed the method to "POST"
though, to indicate that we’re sending data. The headers
is just an object stating that the format we’ll be sending to the API is JSON. The body
is the actual data we’re sending to the API, which is an object containing the movieName
, movieCharacter
, and question
that the user typed into the input boxes. The JSON.stringify() function is used to convert this object into a JSON string that can be sent over the network.
We parse the response by calling response.json()
just like we did before, and we know it only has one key, answer
, which is a string. We set the answer
state to this value and set isLoading
to false
to indicate that we are no longer waiting for the API response.
This prepared handleSubmit
inner function can now be used when the user presses the submit button later down in our code.
Now we need another inner function to handle the change of values in the input boxes. This simply means whenever the user types or removes additional characters in one of the input boxes, before ever clicking the submit button.
const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value);
This is a bit more complex, but it’s not too bad. We define a function handleInputChange
that takes a setter
function as input (like setQuestion
) and returns another function (=> ()
) that takes an event
as input. This function will then call the setter
function with the value of the input box that triggered the event.
I am aware that this may seem very confusing, so let’s break it down a bit more. Later on, we will create instances of our InputField
component which will look something like this:
// Example code, do not copy <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} />
So what we need in the onChange field is a function that takes the event as input, and then uses a setter function to update the React state. This is why the handleInputChange
function simply returns the real function that will take the event as input and then call the provided setter function. So all handleInputChange
does is act as a sort of function factory that creates and returns the function that will update the state variable for the particular state we give it.
This is why in the above example we don’t pass handleInputChange
but rather call it like handleInputChange(setMovieName)
to get the actual function that will update the movieName
state variable and pass that in instead. I hope that makes more sense now! I know it confused me at first.
Dealing with forms in React can be a bit confusing and I honestly wish we had more time to cover it in detail here, but the basic problem is that we have an input box on the HTML page, and we have a state variable. These are two different sources of truth for the same data which is a bad thing. We need to make sure we only have one single source of truth.
This function basically will monitor each input box and whenever even a single character is typed in it will instantly update the state variable. We’ll then set the value
of our input box in the HTML to always be what is stored in the state variable. This way the user types more characters, they instantly get stored in state, and the text box displays this state variable instead of what the user actually typed in the box.
As the state variable will be an exact copy of what the user typed so far, as far as they are concerned, the box contains what they typed, but it actually shows the state variable in there. This way we have only one data source as the HTML state is sort of ‘cut out of the loop’ if you will. Again, sorry for not being able to explain this in greater detail here. Read over the full code one or two more times after we finish this part and it should hopefully make more sense.
Now the actual return part of the BlockbusterChat
will start:
return ( <div className="max-w-2xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> <form onSubmit={handleSubmit}>
We just have a couple of HTML containers div
and form
and a h1
element to display the title of the page. The form
element has an onSubmit
attribute that is set to the handleSubmit
function we defined earlier. This means that when the user clicks the submit button, the handleSubmit
function will be called. Again, I won’t be going into the Tailwind classes here, but they are just for styling purposes.
<div className="flex space-x-4"> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> </div>
We have two InputField
components here, one for the movie name and one for the movie character. They are contained inside a div
element to put them next to one another in a row. Note that these are the InputField
components that we defined ourselves earlier.
We pass the label
, value
, onChange
, and disabled
props to these components. The value
is set to the state variables movieName
and movieCharacter
, the onChange
creates and returns the function to update that particular state using the setMovieName
and setMovieCharacter
functions respectively, and the disabled
prop is set to the isSubmitted
state variable.
<InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-700" disabled={isLoading} > Ask {movieCharacter} </button> </form>
Here we have another InputField
component for the user to type in their question. This time we leave out the disabled
prop as we don’t need it. We also have a button
element that will submit
the form when clicked, styled using some Tailwind classes again. The button is disabled when isLoading
is true
to prevent the user from submitting the form multiple times.
The text on the button is set to Ask {movieCharacter}
which will display the name of the movie character that the user typed in the input box. This is a nice touch to make the button more personal and engaging. Finally, we close the form
element.
{isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )} {answer && ( <div className="mt-6"> <h2 className="text-xl font-semibold">Answer:</h2> <p>{answer}</p> </div> )} </div> ); }; export default BlockbusterChat;
To finish off the component, we have two div
elements that will only be displayed when certain conditions are met. The first div
will display a message when isLoading
is true
to indicate that the API is being called and we are waiting for a response. The second div
will display the answer when it is received from the API.
The way this &&
operator works is that if the condition before it is true
, the element after it will be displayed. If the condition is false
, the element will not be displayed. This is a nice way to conditionally display elements in React. All it means is if (isLoading) { display this div }
and if (answer) { display this div }
.
Finally, we close the div
element and the BlockbusterChat
component and export it as the default export.
Putting it all together
Here is the whole component one last time without all the comments, so you can more properly copy it and see the indentation:
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [answer, setAnswer] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true); const response = await fetch("/api/blockbuster", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question }), }); const data: { answer: string } = await response.json(); setAnswer(data.answer); setIsLoading(false); }; const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value); return ( <div className="max-w-2xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> <form onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> </div> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-700" disabled={isLoading} > Ask {movieCharacter} </button> </form> {isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )} {answer && ( <div className="mt-6"> <h2 className="text-xl font-semibold">Answer:</h2> <p>{answer}</p> </div> )} </div> ); }; export default BlockbusterChat;
So go ahead and save that. Make sure your development server is running (npm run dev
) and go to http://localhost:3000/blockbuster_chat
in your browser. You should see a page with three input boxes and a submit button like this:
So let’s try it out! I’ll ask Pinocchio a question:
Notice that the page displays Asking Pinocchio...
while the API is being called and the answer is being fetched. Once the answer is received, it is displayed automatically. After asking the first question, the Movie Name and Movie Character input boxes are disabled, but the question can be changed.
Sure, it’s not perfect and could be improved in many ways including the styling, but we need to take things one step at a time, Rome wasn’t built in a day. It looks pretty decent now for a V1.0, and it works! We have a chat feature on our Blockbuster page!
Time to take a break and celebrate! π Play around with this and when you’re ready, move on to the next part where we’ll take this to the next level yet again. π