NextJS Course (8/9) – Adding Images to Blockbuster Chat V3

Hi and welcome back to part 8! Kudos for making it this far! πŸŽ‰ It’s time to put the finishing touches on our app and add an image of the character the user is chatting with, as promised in the last part.

The first thing we’ll have to do is update our ChatMessage type to include an optional image URL property. This will allow us to display the image of the character the user is chatting with. So open up the chatMessage.ts file in the types folder:

πŸ“ app
    πŸ“ api
        πŸ“ blockbuster
            πŸ“„ route.tsx
        πŸ“ blockbuster_v2
            πŸ“„ route.tsx
    πŸ“ blockbuster_chat
        πŸ“„ page.tsx
    πŸ“ blockbuster_chat_2
        πŸ“„ page.tsx
    πŸ“ counter
        πŸ“„ page.tsx
    πŸ“ types
        πŸ“„ chatMessage.ts  πŸ› οΈ We'll be working on this file
    πŸ“„ favicon.ico
    πŸ“„ globals.css
    πŸ“„ layout.tsx
    πŸ“„ navbar.tsx
    πŸ“„ page.tsx

We have our type interface defined as follows:

export interface ChatMessage {
  text: string;
  sender: string;
  isAnswer?: boolean;
  time_sent: Date;
}

Now I’ve noticed one thing. This kind of stuff in JavaScript usually uses naming conventions like timeSent instead of time_sent. The reason I used time_sent is because I’m very used to Python naming conventions but say we want to change this to timeSent instead. If we just change the name in here we will break the code in other places where the time_sent property is used.

Luckily for us, VSCode has a feature that will solve this problem for us. Simply click on the time_sent name like so:

Now press F2 on your keyboard and you’ll be able to rename the property:

Change it to timeSent and press Enter. VSCode will now not just rename the property in the current file but also in all the other files where it’s used. Pretty neat, right?

Now let’s add the imageUrl property to our ChatMessage type:

export interface ChatMessage {
  text: string;
  sender: string;
  timeSent: Date;
  isAnswer?: boolean;
  imgUrl?: string;
}

Make sure you use the ? to make the property optional. This way we can have messages without an image URL, and messages before the image finishes generating and loading (that way we don’t have to keep the user waiting).

Adding the Image Generation API

The first thing we’ll need to do is add a backend API that can generate images. So go ahead and create a new folder named get_image inside the API folder and create a new file inside it called route.tsx:

πŸ“ app
    πŸ“ api
        πŸ“ blockbuster
            πŸ“„ route.tsx
        πŸ“ blockbuster_v2
            πŸ“„ route.tsx
        πŸ“ get_image
            πŸ“„ route.tsx  ✨ New file
    πŸ“ blockbuster_chat
        πŸ“„ page.tsx
    πŸ“ blockbuster_chat_2
        πŸ“„ page.tsx
    πŸ“ counter
        πŸ“„ page.tsx
    πŸ“ types
        πŸ“„ chatMessage.ts
    πŸ“„ favicon.ico
    πŸ“„ globals.css
    πŸ“„ layout.tsx
    πŸ“„ navbar.tsx
    πŸ“„ page.tsx

The API we’ll be using is DALLΒ·E 3 by OpenAI. It’s a powerful image generation model that can generate images based on text prompts. The nice thing is that we can use the same API key we used for ChatGPT so we have no extra setup work to be done! The pricing for the image size and quality we’ll be generating here will be $0.04 per image, see https://openai.com/api/pricing/ for more details on other sizes and pricing for the other models.

So open up get_image/route.tsx and add the following code:

import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";

const openAIConfig = { apiKey: process.env.OPENAI_API_KEY };
const openai = new OpenAI(openAIConfig);

This is just our usual setup, nothing new here. Notice how we can use the exact same OpenAI library and key, simple!

Now let’s create the POST route/function that will generate an image and return the URL to the client. As this is fairly similar to the principles you’ve learned so far, I’ll just show the whole code in one go:

export async function POST(request: NextRequest) {
  const { movieName, movieCharacter }: { 
    movieName: string; 
    movieCharacter: string; 
  } = await request.json();

  console.log(`Generating image for ${movieCharacter} from ${movieName}`);

  if (!movieName || !movieCharacter ) {
    return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
  }

  const prompt = `A character from the movie ${movieName} named ${movieCharacter}`;

  const response = await openai.images.generate({
    model: "dall-e-3",
    prompt: prompt,
    n: 1,
    size: "1024x1024",
  });
  const image_url = response.data[0].url;
  console.log(image_url);

  return NextResponse.json({ image_url });
}

We take two variables from the request body, movieName and movieCharacter, and destructure them from the JSON body. Next is a print statement for our debugging purposes and a check like we have done before to make sure we have the required data to continue.

We then create the prompt string for the image generation model and call the openai.images.generate function which is pretty similar to the chat functions we’ve been invoking so far. The model is dall-e-3 and the prompt is the string we created.

A quick tip from my future self: I have noticed that asking directly for A character from the movie ${movieName} named ${movieCharacter} can sometimes lead to the model not generating an image due to a copyright filter being triggered. I advise trying with a slightly more indirect wording like A character in the style of ${movieCharacter} from the movie ${movieName} or something similar if you run into this issue.

The n or number parameter is set to 1 to generate only one image and the size parameter is set to 1024x1024 to generate a 1024×1024 image. Images are generated at standard quality by default, which is more than good enough, so we have omitted that parameter.

We extract the image URL from the response (response.data[0].url) and return it to the client as an object with the key name image_url. We also print it to the console for testing/debugging purposes.

Testing the Image Generation API

Now let’s test it using Postman by sending the following object to the route. Note that you can skip this step if you don’t have Postman or a similar tool installed and test it using the Blockbuster Chat V3 later on:

// Send this to Postman
{
  "movieName": "The Matrix",
  "movieCharacter": "Neo"
}

As you can see you will get an image URL as the response. Open it in your browser to see the generated image:

While it doesn’t look quite like Neo (as the API doesn’t want to break copyright), it is pretty cool and has exactly the right style and vibe to it! πŸ•ΆοΈ This will work great for our chat purposes.

Updating the Navbar

Now that we have a working image generation endpoint we can move on to the frontend part and create Blockbuster Chat V3! πŸš€

First of all, let’s update our Navbar component. Open up the navbar.tsx file in the app folder:

πŸ“ app
    πŸ“ api
        πŸ“ blockbuster
            πŸ“„ route.tsx
        πŸ“ blockbuster_v2
            πŸ“„ route.tsx
        πŸ“ get_image
            πŸ“„ route.tsx
    πŸ“ blockbuster_chat
        πŸ“„ page.tsx
    πŸ“ blockbuster_chat_2
        πŸ“„ page.tsx
    πŸ“ counter
        πŸ“„ page.tsx
    πŸ“ types
        πŸ“„ chatMessage.ts
    πŸ“„ favicon.ico
    πŸ“„ globals.css
    πŸ“„ layout.tsx
    πŸ“„ navbar.tsx  πŸ› οΈ We'll be working on this file
    πŸ“„ page.tsx

Just scroll down to the Navbar component and edit the last link in there like this:

const Navbar = () => {
  return (
    <nav className="bg-emerald-700 text-white p-4">
      <ul className="flex justify-between items-center">
        <NavItem href="/">Home</NavItem> 
        <NavItem href="/blockbuster_chat">Blockbuster Chat V1</NavItem>
        <NavItem href="/blockbuster_chat_2">Blockbuster Chat V2</NavItem>
        <NavItem href="/blockbuster_chat_3">Blockbuster Chat V3</NavItem>
      </ul>
    </nav>
  );
}

We simply pointed the last NavItem link to "/blockbuster_chat_3". Save and close this file and you should have a third link in your navbar now.

Getting started on Blockbuster Chat V3

Now we’ll have to create the Blockbuster Chat V3 page. Create a new folder named blockbuster_chat_3 inside the app folder and create a new file named page.tsx inside it:

πŸ“ app
    πŸ“ api
        πŸ“ blockbuster
            πŸ“„ route.tsx
        πŸ“ blockbuster_v2
            πŸ“„ route.tsx
        πŸ“ get_image
            πŸ“„ route.tsx
    πŸ“ blockbuster_chat
        πŸ“„ page.tsx
    πŸ“ blockbuster_chat_2
        πŸ“„ page.tsx
    πŸ“ blockbuster_chat_3
        πŸ“„ page.tsx  ✨ New file
    πŸ“ counter
        πŸ“„ page.tsx
    πŸ“ types
        πŸ“„ chatMessage.ts
    πŸ“„ favicon.ico
    πŸ“„ globals.css
    πŸ“„ layout.tsx
    πŸ“„ navbar.tsx
    πŸ“„ page.tsx

A lot of this will be repetition which is very similar to the V2 version so we’ll go over the repetitive parts pretty fast. In a real project you would never allow all this duplication and follow the DRY (Don’t Repeat Yourself) principle, but for tutorial purposes, I think it’s nice to have several snapshots of the same code so you can see the progression.

Now open up blockbuster_chat_3/page.tsx and let’s get started on our code. First we’ll import the necessary components and libraries:

"use client";
import React, {
  useState,
  ChangeEvent,
  FormEvent,
  useEffect,
  useRef,
} from "react";
import { ChatMessage } from "../types/chatMessage";

No surprises here, this is all the same as last time. Next up are the InputFieldProps interface and the InputField component. Both of these are the same as the V2 version, so you can simply copy these:

interface InputFieldProps {
  label: string;
  value: string;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  disabled?: boolean;
}

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>
  );
};

Again, outside of a tutorial environment you would never copy stuff like this but put it in a single shared place like we did with the ChatMessage type. But we’re deliberately repeating ourselves here to show the progression and changes made to improve something over time.

Updating the ChatBubble Component

Now we get to the ChatBubble component. This one is going to have several changes:

const ChatBubble: React.FC<ChatMessage> = ({
  text,
  sender,
  isAnswer = false,
  timeSent,
  imgUrl,
}) => {
  const chatLocation = isAnswer ? "chat-end" : "chat-start";
  return (
    <div className={`chat ${chatLocation}`}>
      {/* Conditionally render image */}
      {isAnswer && imgUrl && (
        <div className="chat-image avatar">
          <div className="rounded-2xl w-32">
            <img src={imgUrl} alt="Avatar image" />
          </div>
        </div>
      )}
      {/* Rest of the chat message */}
      <div className="chat-header">
        {sender === "You" ? (
          <span className="font-bold mr-1">You</span>
        ) : (
          <span className="font-bold text-custom-grey mr-1">{sender}</span>
        )}
        <time className="text-xs opacity-50">
          {timeSent.toLocaleTimeString()}
        </time>
      </div>
      <div className="chat-bubble">{text}</div>
    </div>
  );
};

We can see in the input destructuring we now have timeSent under the new name and imgUrl as a new property. We also have a new conditional rendering block in between the two comment lines. If this message isAnswer and there is an imgUrl then it will render this image. The specific structure and classNames are simply copied from the daisyUI chat bubble documentation like before, except we insert our own image.

Calling the Image Generation API

Now that we have our updated ChatBubble component we need another function that will handle the call to our backend image API for us:

const getImageForMovieChar = async (
  movieName: string,
  movieCharacter: string,
  setIsFirstRequest: React.Dispatch<React.SetStateAction<boolean>>,
  setImgUrl: React.Dispatch<React.SetStateAction<string>>
) => {
  setIsFirstRequest(false);
  try {
    const response = await fetch("/api/get_image", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        movieName,
        movieCharacter,
      }),
    });

    if (!response.ok) {
      throw new Error("Failed to fetch image");
    }

    const data = await response.json();
    setImgUrl(data.image_url);
  } catch (error) {
    console.error("Error fetching image:", error);
  }
};

We have a function that takes 4 input arguments. The movieName and movieCharacter are the two strings we need to generate the image. The setIsFirstRequest and setImgUrl are two state setters that we need to update the state of the component. So these will be the functions that update the state of the main Blockbuster Chat V3 component to let it know what the image URL is and whether we have already made a request.

The React.Dispatch<React.SetStateAction<boolean>> TypeScript type just means that this setter function deals with React.Dispatch which deals with a SetStateAction which deals with a boolean type. Don’t worry too much about these type-names if you find them confusing.

The first thing we do in the body of the function is set isFirstRequest to false as we never want to generate more than one image per chat. Now we simply make a request to our own backend API, like we have done with our other APIs many times before, passing in the required data inside the JSON.stringify stringified body.

If the response is not ok, we throw an error (which we catch again in the catch clause below) and if it is ok we extract the image URL from the response by parsing the .json(). Finally, we update the imgUrl state using the setImgUrl function which will be passed in from the main Blockbuster Chat V3 component so that the response is communicated back.

The main BlockbusterChat Component

Now it’s time to start on our BlockbusterChat component. As is customary I’ll first show it in parts as it is so big, and then I’ll put the entire thing in there once more at the end for clarity.

const BlockbusterChat: React.FC = () => {
  const [movieName, setMovieName] = useState<string>("");
  const [movieCharacter, setMovieCharacter] = useState<string>("");
  const [question, setQuestion] = useState<string>("");
  const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
  const [isFirstRequest, setIsFirstRequest] = useState<boolean>(true);
  const [imgUrl, setImgUrl] = useState<string>("");

This is largely the same except for the last two states. We have seen why we need both of these above, the isFirstRequest state is used to make sure we only generate one image per chat, and the imgUrl state is used to store the image URL we get back from the API.

Next is the chatHistoryRef, which stays the same as in the V2 version:

  const chatHistoryRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (chatHistoryRef.current) {
      chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight;
    }
  }, [chatHistory]);

It is the same as before, we scroll to the bottom of the chat history whenever the chat history changes.

Now the handleSubmit function, which is mostly the same except for the part between the comments:

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setIsSubmitted(true);
    setIsLoading(true);

    // This part is new
    if (isFirstRequest) {
      getImageForMovieChar(
        movieName,
        movieCharacter,
        setIsFirstRequest,
        setImgUrl
      );
    }
    // End of new part - the rest is the same

    const questionChatMessage: ChatMessage = {
      text: question,
      sender: "You",
      timeSent: new Date(),
    };
    setChatHistory((prevChatHistory) => [
      ...prevChatHistory,
      questionChatMessage,
    ]);

    const response = await fetch("/api/blockbuster_v2", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        movieName,
        movieCharacter,
        question,
        chatHistory,
      }),
    });

    const data: { answer: string } = await response.json();

    const answerChatMessage: ChatMessage = {
      text: data.answer,
      sender: movieCharacter,
      isAnswer: true,
      timeSent: new Date(),
    };
    setChatHistory((prevChatHistory) => [
      ...prevChatHistory,
      answerChatMessage,
    ]);

    setQuestion("");
    setIsLoading(false);
  };

So let’s have a look at the new part between the comments. If the state of isFirstRequest is true, which is its default value, we call the getImageForMovieChar function we defined earlier. We pass in the movieName, movieCharacter, setIsFirstRequest, and setImgUrl functions as arguments.

Notice that we never use the await keyword to wait for the result (our function doesn’t return anything, so we couldn’t even await it if we wanted!), even though the getImageForMovieChar function we wrote is clearly an async function. This is because we don’t want to wait for the image to be generated. We want to keep the chat flowing as this image-generation process can take quite a while. So the API call will simply be triggered just like we coded it, and then the code will move on to running our other functions.

It will come back to finish the getImageForMovieChar function when a response is received later on. Our function will then update the imgUrl state which will let React know that it needs to re-render the component with the new image as imgUrl now has a value that we set to conditionally render inside our ChatBubble component if there is a value, and our page will update automatically!

The handleInputChange method once again requires no changes:

  const handleInputChange =
    (setter: React.Dispatch<React.SetStateAction<string>>) =>
    (event: ChangeEvent<HTMLInputElement>) =>
      setter(event.target.value);

The return statement is almost the same, so I will just paste it here with comments whenever something has changed compared to the previous version:

  return (
    <div className="max-w-4xl mx-auto p-4"> 
    {/* The above was 3-xl but now 4-xl in V3 */}
      <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1>

      {/* Initial input form for character and first question */}
      {!isSubmitted && (
        <form onSubmit={handleSubmit}>
          <div className="flex space-x-4">
            <InputField
              label="Movie Character"
              value={movieCharacter}
              onChange={handleInputChange(setMovieCharacter)}
              disabled={isSubmitted}
            />
            <InputField
              label="Movie Name"
              value={movieName}
              onChange={handleInputChange(setMovieName)}
              disabled={isSubmitted}
            />
          </div>
          <InputField
            label="Question"
            value={question}
            onChange={handleInputChange(setQuestion)}
          />
          <button
            type="submit"
            className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900"
            disabled={isLoading}
          >
            Ask
          </button>
        </form>
      )}

      {/* Display chat history */}
      {chatHistory && (
        <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll">
          {chatHistory.map((chatMessage: ChatMessage, index) => (
            <ChatBubble
              key={index}
              text={chatMessage.text}
              isAnswer={chatMessage.isAnswer}
              sender={chatMessage.sender}
              timeSent={chatMessage.timeSent}
              imgUrl={imgUrl}
            />
          ))}
          {/* We added the imgUrl input argument */}
        </div>
      )}

      {/* Loading message */}
      {isLoading && (
        <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div>
      )}

      {/* Display form for follow up questions */}
      {isSubmitted && !isLoading && (
        <form className="mt-3" onSubmit={handleSubmit}>
          <div className="flex space-x-4">
            <InputField
              label="Question"
              value={question}
              onChange={handleInputChange(setQuestion)}
            />
            <button
              type="submit"
              className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900"
              disabled={isLoading}
            >
              Ask
            </button>
          </div>
        </form>
      )}
    </div>
  );
};

export default BlockbusterChat;

As you can see, not much has changed here. Just make sure you change the max-w-3xl to max-w-4xl in the div element at the top of the return statement and add the imgUrl input argument to the ChatBubble component. Here is the whole component with the changes in case you need it:

const BlockbusterChat: React.FC = () => {
  const [movieName, setMovieName] = useState<string>("");
  const [movieCharacter, setMovieCharacter] = useState<string>("");
  const [question, setQuestion] = useState<string>("");
  const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
  const [isFirstRequest, setIsFirstRequest] = useState<boolean>(true);
  const [imgUrl, setImgUrl] = useState<string>("");

  const chatHistoryRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (chatHistoryRef.current) {
      chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight;
    }
  }, [chatHistory]);

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setIsSubmitted(true);
    setIsLoading(true);

    if (isFirstRequest) {
      getImageForMovieChar(
        movieName,
        movieCharacter,
        setIsFirstRequest,
        setImgUrl
      );
    }

    const questionChatMessage: ChatMessage = {
      text: question,
      sender: "You",
      timeSent: new Date(),
    };
    setChatHistory((prevChatHistory) => [
      ...prevChatHistory,
      questionChatMessage,
    ]);

    const response = await fetch("/api/blockbuster_v2", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        movieName,
        movieCharacter,
        question,
        chatHistory,
      }),
    });

    const data: { answer: string } = await response.json();

    const answerChatMessage: ChatMessage = {
      text: data.answer,
      sender: movieCharacter,
      isAnswer: true,
      timeSent: new Date(),
    };
    setChatHistory((prevChatHistory) => [
      ...prevChatHistory,
      answerChatMessage,
    ]);

    setQuestion("");
    setIsLoading(false);
  };

  const handleInputChange =
    (setter: React.Dispatch<React.SetStateAction<string>>) =>
    (event: ChangeEvent<HTMLInputElement>) =>
      setter(event.target.value);

  return (
    <div className="max-w-4xl mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1>

      {/* Initial input form for character and first question */}
      {!isSubmitted && (
        <form onSubmit={handleSubmit}>
          <div className="flex space-x-4">
            <InputField
              label="Movie Character"
              value={movieCharacter}
              onChange={handleInputChange(setMovieCharacter)}
              disabled={isSubmitted}
            />
            <InputField
              label="Movie Name"
              value={movieName}
              onChange={handleInputChange(setMovieName)}
              disabled={isSubmitted}
            />
          </div>
          <InputField
            label="Question"
            value={question}
            onChange={handleInputChange(setQuestion)}
          />
          <button
            type="submit"
            className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900"
            disabled={isLoading}
          >
            Ask
          </button>
        </form>
      )}

      {/* Display chat history */}
      {chatHistory && (
        <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll">
          {chatHistory.map((chatMessage: ChatMessage, index) => (
            <ChatBubble
              key={index}
              text={chatMessage.text}
              isAnswer={chatMessage.isAnswer}
              sender={chatMessage.sender}
              timeSent={chatMessage.timeSent}
              imgUrl={imgUrl}
            />
          ))}
        </div>
      )}

      {/* Loading message */}
      {isLoading && (
        <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div>
      )}

      {/* Display form for follow up questions */}
      {isSubmitted && !isLoading && (
        <form className="mt-3" onSubmit={handleSubmit}>
          <div className="flex space-x-4">
            <InputField
              label="Question"
              value={question}
              onChange={handleInputChange(setQuestion)}
            />
            <button
              type="submit"
              className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900"
              disabled={isLoading}
            >
              Ask
            </button>
          </div>
        </form>
      )}
    </div>
  );
};

export default BlockbusterChat;

Apologies for the repetition but this is the easiest way to point out exactly where to make the changes to an already existing and rather large code block, instead of trying to describe exactly where to make these small changes without showing you.

Putting it to the test

Awesome, so go ahead and save that. Make sure your dev server is running and open your website in the browser. You should now see a third link in the navbar called Blockbuster Chat V3, and the input boxes will be a bit wider to give us extra space for the image:

So let’s try it out, pick a character and a movie and ask a question. It will take a while to generate the image so your chat will start and the image will appear later:

This really adds a new dimension to our chat as I feel I am really talking to Godzilla now! πŸ¦–

As expected, Sherlock Holmes is a bit more mysterious and uses overly fancy English. πŸ•΅οΈβ€β™‚οΈ

Of course, it will not look 100% like the movie character for copyright reasons, but that is still pretty cool! You can now ask your favorite movie characters all those weird questions you’ve always been curious about! πŸŽ₯

That’s our app with 3 different versions finished! Feel free to play with the styling, add more features, or even create your own version of the chat app! Maybe you want the images to be bigger, maybe you want to have a spinner there to indicate they are loading, or maybe you want to build a completely different app.

Before we go though, I’d like to cover one more very important topic. It’s nice that we have built a website and it has a working front end and back end with APIs and all, but how do we actually deploy this website so that other people can see it? πŸ€”

A website is not a real website if it only runs on our local computer! If we want to develop a real AI application then we’ll need to learn about deployment. So head over to the last and final bonus tutorial part to learn how to deploy your website to the internet! 🌐

Leave a Comment