This AI course teaches you how to build AI-powered apps with React & Express. You’ll learn about LLMs, prompt engineering, and full-stack AI integration.
🚀 Want to dive deeper?
- Get the full course: https://mosh.link/build-ai-powered-apps
- Subscribe for more videos like this: https://goo.gl/6PYaGF
💡 Related tutorials
https://youtu.be/SqcY0GlETPk?si=ALGbprFSjzSnVfms
https://youtu.be/d56mG7DezGs?si=NamrMlwEwC99MIfQ
✋ Stay connected:
- Full Courses: https://codewithmosh.com
- Twitter: https://twitter.com/moshhamedani
- Facebook: https://www.facebook.com/programmingwithmosh/
- Instagram: https://www.instagram.com/codewithmosh.official/
- LinkedIn: https://www.linkedin.com/school/codewithmosh/
📖 Chapters
0:00:00 Welcome
0:01:26 Prerequisites
0:02:21 What You’ll Learn
0:06:15 Setting Up Your Development Environment
0:07:12 Introduction to AI Models
0:07:48 Rise of AI Engineering
0:11:49 What Are Large Language Models?
0:16:12 What Can You Do With Language Models?
0:18:37 Understanding Tokens
0:21:40 Counting Tokens
0:25:43 Choosing the Right Model
0:30:45 Understanding Model Settings
0:39:32 Calling Models
0:47:07 Setting Up a Modern Full-Stack Project
0:48:19 Setting Up Bun
0:49:51 Creating the Project Structure
0:52:39 Creating the Backend
0:59:18 Managing OpenAI API Key
1:04:33 Creating the Frontend
1:07:18 Connecting the Frontend and Backend
1:12:31 Running Both Apps Together
1:15:55 Setting Up TailwindCSS
1:19:30 Setting Up ShadCN/UI
1:26:00 Formatting Code With Prettier
1:31:02 Automating Pre-Commit Checks With Husky
1:37:53 Project: Building a ChatBot
1:38:22 Building the Backend
1:38:58 Building the Chat API
1:45:25 Testing the API
1:47:22 Managing Conversation State
1:53:44 Input Validation
1:59:33 Error Handling
2:01:52 Refactoring the Chat API
2:04:00 Extracting Conversation Repository
2:09:21 Extracting Chat Service
2:16:05 Extracting Chat Controller
2:20:03 Extracting Routes
2:24:55 Building the Frontend
#ai #aiappdevelopment #aicourses
Оглавление (38 сегментов)
Welcome
AI is everywhere, but have you actually built something with it? In this course, you will learn how to build real AI powered features like the ones you see in apps from Google, Amazon, and beyond. We'll start by building a solid foundation, understanding language models, tokens, context windows, choosing the right models, model settings, and prompt engineering. Then we'll build projects starting with a chatbot that can answer questions about an imaginary theme park, helping visitors find what they need faster. Next, we'll create a tool that analyzes customer feedback and delivers clear, actionable insights so users can make shorter decisions in seconds. And finally, we'll wrap up with powerful open-source integrations you can run anywhere. Along the way, you will learn clean architecture principles, follow best practices, and work with modern tools like Bun, Tailwind, Shatsen, Prisma, and Olama. By the end, you will have the skills and confidence to build AI powered apps people actually want to use. I'm Mash Hamadani, a software engineer with over 20 years of experience, and I've taught millions how to code and become professional software engineers through my YouTube channel and online school, codewithmarsh. com. If you're new here, make sure to subscribe as I upload new videos all the time. Now, let's jump in and get started.
Prerequisites
Before we dive in, let's talk about what you'll need to know to get the most out of this course. The ideal student is someone who already knows the basics of front-end development and wants to bring AI into their applications to make them more engaging and smarter. That means you should be comfortable with modern JavaScript and TypeScript things like arrow functions, destructuring, promises, and async and await. You should also know how to build simple React applications. You should know how to create components, work with JSX, and use the state and effect hooks. Just the basics. You don't need to be a React expert. If you know a bit of backend development and databases, that's great, but not required. The same goes with AI. You don't need any prior experience. I will walk you through everything step by step. If that sounds like you, you're ready to get started.
What You’ll Learn
Assuming that you're the right student for this course, let me give you a quick overview of how it's structured and what to expect. First, let me tell you what this course is not. If you're looking for one of those build an app with AI in 1 hour miracles, you know, where you paste a few prompts into chat GPT, sit back with the latte, and poof, your billion dollar startup is ready. This isn't that course. We are not doing VIP coding here. We're building smart features with AI, the kind that actually make your apps more useful and more engaging. And we'll do it the right way so you understand exactly what's going on under the hood. All right, here's how the course is laid out. In section one, we'll start with the foundations. You'll learn what language models are, what they can do, and how to work with them. We'll talk about tokens, context windows, model settings like temperature, and how to call models. This section sets you up with the concepts you need before we dive into building. Section two is all about setting up a modern full stack project from scratch. Now, we could use Nex. js JS here, but I decided not to because not everybody uses it or even likes it and some developers prefer to keep the front end and back end completely separate. So in this course, we'll keep the back end and front end completely separate. This helps you clearly see how the two parts talk to each other. We'll use a modern stack with bun, express, react, tailwind, and chaten. And don't worry if you haven't used these tools before. I'll teach you everything from scratch. Now, if you prefer Nex. js, JS, you can still apply everything you learn in this course to a Next. js project. The principles are exactly the same. Section 3 is our first big project, a chatbot that can answer questions about an imaginary theme park to help visitors find the information they need faster. We'll start with the back end, build it step by step, and then refactor it using clean architecture principles. Once the back end is solid, we'll build the front end the same way. We'll add features, improve the UI, and make the chatbot better with each lesson. This is where I want you to code along with me. Don't just watch. Understand the code, the problems we run into, and why we solve them the way we do. If it feels like I'm going too fast, just watch one lesson at a time, take notes, and then repeat it. The lessons are short and focused, so you won't need to remember a lot at once. In section four, we'll dive into prompt engineering, the art of writing prompts that actually get the results you want. You will learn how to give context, control output format, use examples, handle errors, and reduce hallucinations. Section five is another full stack project. This time, a product review summarizer. You have probably seen this on a lot of websites. A quick summary of reviews so you can decide faster. We'll build our own version from scratch, complete with a database and Prisma migrations. It's a bigger, more complex project than the chatbot. So, we'll get to explore a lot of techniques and best practices that most courses skip. And here's the cool part. What you learn here isn't just for summarizing text. You can apply the same techniques to build all kinds of AI powered features. Finally, section six is about working with open-source models. You'll learn why they matter, how to find them, how to run them locally, and how to integrate them into our applications. This opens up a whole new world where you're not tied to commercial APIs. Now, throughout the course, I'll share tips, tricks, and shortcuts you don't want to miss. That's why it's important to watch every lesson in order and code along, especially during the projects. This is a hands-on course, and you will get the most out of it by being active from the very beginning. Oh, and by the way, what you're watching here on YouTube is actually the first two hours of my full 7-hour course. I'm giving you this part for free so you can get a feel for my teaching style and see if you want to go deeper. Next, we're going to set up our development environment.
Setting Up Your Development Environment
Before we dive into coding, let's quickly set up our development environment. First, make sure you have the latest version of Node on your machine. So, open up a terminal window and run node-v. Look, on this machine, I'm running node version 22. 17. So, make sure you have the same version or higher. If not, head over to node. js. org and download the latest version. Now, for coding, we'll be using VS Code. You're welcome to use your preferred editor, but I highly encourage you to use VS Code because throughout the course, I'm going to show you a lot of cool shortcuts and useful plugins that not only help you write code faster, they also make your job more fun and enjoyable. So, that's all you need for now. Node. js and VS Code. We'll install any other tools as we go through the course. All right, with your environment ready now, let's get started.
Introduction to AI Models
started. Welcome back. In this section, we'll lay the foundation for everything we'll build in this course. We'll explore what language models are, what we can do with them, and how to use them effectively in real world applications. We'll also cover practical concepts like tokens, cost, model selection, and key settings that shape the model's behavior. Even if you have used Chat JP before, this section will give you a solid mental model. So, when we start building, you'll know exactly how and why things work the way they do. Now, let's jump in and get started.
Rise of AI Engineering
Since Chat JPT came out, the software world has changed fast. New models are being released every month, new tools, new APIs, new expectations, and new job titles. One of the most exciting ones is AI engineer. You have probably seen it pop up in job postings. But what exactly is that? Well, it's not the same as a machine learning engineer. Machine learning engineers build and train models. They clean data, tune architectures, and optimize training pipelines. It's mathheavy and research focused. AI engineers, on the other hand, use pre-trained models, especially large language models, to build smarter applications. They don't need to understand the math behind the model. They need to understand how to use it and how to integrate it into real world apps. It's a lot like using a database. As a software engineer, you don't need to know how MySQL works internally. You just to query it, structure your data, and build a reliable product. In the same way, as an AI engineer, your job is to understand how to use these powerful AI models to solve real problems. Right now, companies around the world are hiring engineers who know how to build AI powered features. things like summarization, translation, intelligent search, automation, and personalized UX. Let me show you just a few examples. Amazon now shows an AI generated summary of product reviews on the product page. This saves shoppers time and increases conversion by making the buying decision faster. You'll see the same pattern in many other applications where AI is being used to surface quick takeaways from long threads. As another example, Active Campaign, which is a marketing platform, lets marketers use AI to generate full email campaigns with just a few prompts. Instead of starting from scratch, they get instant draft content they can publish and send. Here's another example. On Twitter or X, if you see a post in another language, you'll often see a translate link. Behind the scenes, a large language model detects the language, determines your local, and generates a translated version instantly. This feature is becoming standard across social platforms and news applications. Here's another example. Platforms like YouTube and Twitch use AI to automatically flag things like spam, hate speech, or inappropriate content. It helps keep communities safe without needing thousands of human moderators watching everything in real time. Here's another example. Freshesk, which is a customer support platform, uses AI to automatically categorize, prioritize, and route incoming support tickets. So instead of agents manually sorting through every request, the system sends each ticket to the right team. And that means agents spend less time organizing and more time solving. Let's look at another example. On Redfin, which is a platform for finding homes, when we are viewing a property listing, there's a built-in chat assistant that can answer questions about that specific property. So, instead of waiting to talk to an agent, users can get the basic info they need right away. And these are just a few examples. The possibilities are endless. Everyday developers are adding smart features like these into their applications. Not just for novelty, but to save time, reduce costs, and build smarter, more helpful experiences. I believe that going forward, every software engineer will be expected to know how to work with AI models just like we're expected to work with databases today. You will need to know about large language models or LLMs, prompt engineering, retrieval, augmented generation or rag, vector databases, building agents, and so on. This course is your first step into that world. So, if you're a developer and you want to keep up with where the industry is headed, you're in the right place. In the next lesson, we'll break down what a large language model actually is in simple practical terms.
What Are Large Language Models?
Now that you understand why learning AI matters, let's start unpacking the fundamentals. In this lesson, we're going to answer a basic but important question. What is a large language model? Let's break it down. At its core, a language model is a system that's trained to understand and generate human language. There are several language models available today. Some are commercial like GPT by OpenAI, Gemini by Google, Claude by Anthropic, Grock by XAI which is Elon Musk's company. We also have open- source models like Llama by Meta, Mistral by European company also named Mistl and many more. They're called large because they're trained on massive amounts of text. Everything from books and articles to forums, code, documentation, and more. Through this training, they learn statistical patterns in a language. Things like grammar, sentence structure, tone, common facts, and phrasing. So when we prompt an LLM with, let's say, the capital of France is, it doesn't look up the answer. It simply predicts what a helpful response should look like based on patterns it has seen during training. It's like autocomplete but on steroids. In practice, a large language model is a giant mathematical structure, usually multiple gigabytes in size, made up of billions of parameters. Those parameters represent patterns in language like grammar, facts, tone, and style. These models don't understand language the way we do. They don't have beliefs or intelligence. They're just very good at predicting what comes next. The output is often so well written, so fluent on structure that it feels like there's intelligence behind the scenes. But there isn't. It's just mass and probability based entirely on training data. That's why if you ask chat GPT the same question multiple times, we'll often get slightly different answers. It's not repeating a stored response. It's generating new output each time based on likelihood, not truth. And that brings us to a very important point. Because these models don't understand what they're saying, the quality of training data is everything. If a model is trained unbiased, inaccurate, or lowquality data, its responses will reflect that. That's why some models appear politically biased or why some models give completely false answers with total certainty. It all comes back to the data. These days, many people use language models to generate code and it's impressive at first. But here's the issue. These models are trained on billions of lines of code from public repositories including GitHub. And a huge portion of that code is poorly written, outdated, filled with antiatterns or just broken. The model doesn't know that. It simply learns what's common, not necessarily what's correct or maintainable. So when it generates code, it might look clean, sound confident, even compile, but it could be buggy, insecure, or full of bad practices. That's the danger of blindly trusting model generated code. We get something that looks professional but isn't always reliable. So once again, I want to emphasize that training matters a lot. A model is only as good as the data it's trained on. Garbage in, garbage out. If it learns from clean, highquality code and accurate language data, it performs well. If it trains on messy, biased, or incorrect data, the responses can be misleading or flatout wrong. Now, training a large model from scratch isn't just about data. It also requires enormous amounts of compute power. We're talking thousands of GPUs, weeks, or months of continuous training, and infrastructure that only a handful of companies in the world can afford. That's why most of us don't train models ourselves. As developers, our job isn't to become machine learning engineers. It's to understand how to talk to these models via prompting, how to handle their limitations, and how to integrate them into our applications to build smarter features. Just like we don't need to build our own database engine, we just need to know how to use one. That's the mindset you will develop throughout this course. In the next few lessons, we'll dig deeper into how these models work, things like tokens, cost, and how to pick the right one for the job.
What Can You Do With Language Models?
job. So I told you that as a developer you need to learn how to integrate language models into your applications. Now let me show you what that actually looks like. Think about a typical application structure. We have a front end maybe built with React or something similar, a back end, a database for storing our application data and now a language model ready to generate or process content. The LLM is usually not the center of our application. It's a supporting system. We send it input or a prompt. We get a response and use that response to enhance the user experience. And the way we use it depends on our feature. For example, a very common use case is summarization. I showed you how Amazon uses that to summarize reviews. This is getting very common in modern applications. We can also use LLMs for generating content like emails, product descriptions, social media posts and so on. Another common use case is text classification. We can use a model to categorize input. For example, is this spam or not? Is this review positive or negative? Is this support ticket about billing, login or cancellation? We can ask LLMs to generate responses as JSON objects like this. That means our back end can easily parse the response, store it and make decisions based on it. We can also use LLMs for translating text from one language to another. I showed you how Twitter or X does this. Also, the new iOS does that to translate texts in real time. Another great application of language models is in extracting information. For example, we can give an LLM some messy text like a PDF and ask it to pull out structured data like invoice number, amount, names, addresses, and so on. We can also use LLMs to build and integrate chat bots into our applications. We can build chat bots that answer questions based on user data or business documents and so on. All of these use cases follow the same pattern. Text in, text out. We give the model a prompt and it gives us back a response. The response can be plain text. It can be an array, a JSON object, a number, an image, or whatever is useful. Now that you have seen what LLMs can do, let's look at what's actually inside them. In the next lesson, we'll talk about how these models work under the hood.
Understanding Tokens
Now that you know what language models are and what we can do with them, let's take a closer look at something that plays a big role in how we use them effectively, and that's tokens. What are tokens? Well, when we send a prompt to a language model, it doesn't process the input as plain text. Instead, it breaks the text down into smaller units called tokens. These tokens can be whole words, parts of words, punctuation, even emojis or spaces. So tokens aren't the same as characters or words. They fall somewhere in between. To see this in action here on Google, search for OpenAI tokenizer. On this page, you can type a prompt here or click show example. Look, this piece of text has 252 characters and it's broken down into 53 tokens. Down below you can see these tokens colorcoded. So each piece represents a token. Now why does this matter? Because tokens directly impact cost. Let's take open as an example. At the time of this recording, generating 1 million output tokens with GPT40 mini costs 60. With GPT4. 1, the same task would cost $8. That's 13 times more. So if you're summarizing long documents or generating large amounts of content, token usage and therefore cost can add up quickly. That's why when choosing a model, cost should be one of the key factors. We shouldn't just go for the latest or most powerful model. Think about what your app actually needs. It's kind of like buying a phone. You don't always need the latest iPhone Pro Max. Sometimes a mid-range phone gives you everything you need. Same logic applies here. So tokens cost money, but there's also a limit to how many tokens a model can handle at once. That limit is called the context window. The context window includes our prompt, which is the input, the model's response, and the chat history. That's if we're building a conversational experience. Again, all of this is measured in tokens. For example, GPT4 Mini has a context window of about 128,000 tokens. GPT 4. 1 can handle around 1 million tokens. Mistrol, which is an open-source model, supports around 32,000 tokens. So, if we send a very long prompt and hit the token limit or the context window, the model will stop even in the middle of a sentence. That's why it's important to know how much context our model can handle. But again, we don't always need the largest context window or the biggest model. Mistrol, for example, might be perfectly sufficient for tasks like summarizing a blog post or classifying a support ticket. It all depends on the needs of our application. In the next lesson, I'm going to show you how to count tokens programmatically so we can estimate cost and stay within limits before sending a request.
Counting Tokens
All right. Now, let me show you how to count tokens in code. So, open up a terminal window. Let's go to our desktop or somewhere on your machine and create a directory called playground. This is going to be our playground project. Next, we cd into this directory and run npm init-y to create a package. json file without answering basic questions about our project like its name, version, and so on. So, here's our package. json file. Great. Now we're going to install a library called tick token. This is the tokenizer used by open AI models. So different AI models, different platforms have their own tokenizer library. Okay, good. Now to open this in VS Code, we're on code space period. Now if this doesn't work on your machine, just drag and drop this directory onto VS Code. All right. Now we add a new file here, index. js JS on the top we import a function called get encoding from tick token. Next we call get encoding and give it an argument. The argument is an encoding. Here we have a few options. These options or these encodings are dictionaries that map token ids to the actual tokens. For example, we might have a token ID let's say 9004 that maps to the word hello. Okay. So we're going to pick CL 100K underline base that is short for chat language and 100K means in this dictionary we have about 100,000 unique tokens. So we get an encoding and store it in a constant. Next we call encoding encode and give it a piece of text like hello world. This is the first test of tick token library. Okay, next we get our tokens and store them in a constant. And finally, we log them on the console. Okay, now let's open up a terminal window here by pressing control and backtick. Now let's run our application by running node index. js. All right, we got a syntax error saying cannot use import statement outside a module. Why are we getting this? Because by default node interprets our JavaScript files as common JS modules. In common JS modules we have a different format for importing functions. So instead of this syntax which is called ES module syntax we have a different syntax which is called common. js syntax. So we have to write code like const get encoding equals require t take token that is the common JS format which is older nobody uses that anymore so to tell NodeJS to use the new format we have to go to our package JSON file we can open it right here package. json JSON, we set the type property to module. Okay, now back to the terminal. Let's rerun index. js. All right, look. We got an array of 13 items where each item is a number. Each number is a token ID that maps to an actual token. So when working with large amount of text, we can use the tick token library to count tokens before sending a prompt to a language model. Hey, just a quick reminder, as I told you at the beginning of this video, what you're watching right now is actually the first 2 hours of my full 7-hour course on building AI powered apps. So once you finish this tutorial, if you want to learn more, check out the link in the description to enroll in the complete course. I would love to see you inside.
Choosing the Right Model
inside. These days there are hundreds of AI models out there and new ones are released almost every week. So which model should we choose? There is no single right answer. The model we choose really depends on our application and its requirements. In this lesson, I'm going to give you a framework to make that decision. Now, I'm not going to give you a fixed list of model names to choose because model names change quickly. So instead, we're going to focus on the criteria that matter when choosing a model. The first question we need to ask is how smart does the model have to be? If we want to solve complex problems, we want a model with stronger reasoning. But if we just need to extract text, classify a review, or summarize a short paragraph, we don't need the top tier model. A smaller model is good enough to do the job. The next question is, how fast do we need a response? Bigger models are often slower, especially when generating long outputs. If you're building a real-time user experience, like autocomplete, quick summaries, or short form answers, we'll want a faster model. The next question is, what kinds of input and output are we going to send to and receive from a model? Text is the most common, but newer models can also process images, audio, video, or even a combination of those. These are called large multimodal models or LMMs. So if our application involves anything beyond text, for example, describing what's in an image, then we need a multimodal model. The other factor is cost and I told you that this is based on the number of tokens. So if you're processing long documents or generating a lot of content, cost can add up quickly. The next factor is the context window. We talked about this before. That's how much text the model can process at once. And that includes our input, the model's response, and the chat history. If you're building a conversational experience, if you're summarizing long documents, analyzing code bases, or having long back and forth conversations, we need a model with a large context window. The other factor is privacy. If our application is processing sensitive data like patients, medical records, we probably don't want to send that data to OpenAI servers. This is where open-source self-hosted models can help. We'll talk about that later in the course. Now, to see this in action on Google, search for OpenAI models. On this page, we can see the featured models. At the time of this video, we have GPT4. 1, O4 Mini, and 03. Now, up here, we can click compare models and compare these three models or any other models you're interested in. Now look, 03 is their most powerful reasoning model. You can see that indicated down here. But this is also the slowest of all three models. So there is no best single model. If you're building an application that requires solving complex problems, then we need a model with strong reasoning capability. But otherwise, if all we want to do is let's say summarize documents or extract structured data, we can go with a simpler model. Now all these three models as you can see are multimodel models because they support text and image as the input. But for the output they can only generate text. So if you need to generate images in our application then we have to pick a different model. At the time of this video we have a model called GPT image one. Now this model can support text and image as the input but it can only generate images in the output. So it cannot generate any text. Now that aside down below you can see the pricing. We have different prices for input and output. So if we need to process large documents like contracts, cost can add up pretty quickly. So always compare the cost of models and go with the one that fits your application. Now moving on below that we have the context window of these models. So this model has the context window of 200,000 token. This other model has a context window of about 1 million tokens. Now, we also have another factor here that's max output tokens. That's the number of tokens that can be in one response. So, this model, even though it has a larger context window, it can process more text or longer conversations, but each response can be a maximum of 32,000 tokens. In contrast, with this model, our responses can be three times larger. The other factor we have here is the knowledge cutoff and that's when the training of these models stopped. So sometimes you can find older models that don't have up-to-ate knowledge of the world but they could be perfect for your application. So once again there is no best model. All right, we're done with this lesson. In the next lesson we're going to talk about model settings and see how different parameters affect the output.
Understanding Model Settings
output. In this lesson, we'll take a look at the settings that control how a language model behaves. So, here on the OpenAI website, on the models page, let's grab GPT 4. 1. On this page, you can see all the details about this model like its level of intelligence, the speed, the price of input and output tokens, as well as the modalities. We can also see the context window, max output tokens and the knowledge cutoff date. Now up here we can try this in the playground. First we have to log in. All right. Here's the model playground. Now the first time you want to use this, you have to add credit to your account. So up here, click the settings icon. Then go to the billing page. On this page, you can add your debit or credit card and add some credit to your account. Once you do that, now let's go back to the playground. So, the model is GPT4. 1. We can change this to any models. Now, all these models have a few common settings we're going to talk about in this lesson. The first one is text format. Here we have three options. We have text, which is plain text, as well as JSON object and JSON schema. Let's see the differences in action. So I'm going to go with text and send a prompt like give me three benefits of exercising. So this gives us a short answer in plain text. We have all seen this before. Now let's see what happens if we change this to JSON object. Now we repeat the prompt and say give me three benefits of exercising but we also add as a JSON object. Now take a look. The model generated a JSON object with this format. So we have an object with a single property called benefits of exercising which is an array of strings. Now if the response is not colorcoded on your machine, just click this icon. With this you can toggle between plain text and JSON format. Okay. So JSON is useful if you want to parse the response in our application. But what if we expect a different type of JSON object? So instead of having a property like benefits of exercising, let's say we want to have a property like exercise which would be an object with another property called benefits. This is where we can use a JSON schema. So we change the format to JSON schema. In this box, we should define the shape of our JSON objects. Now this format is a little bit complicated. So it's easier to generate it using AI. Here we can describe the kind of JSON object we expect in the response. For example, we can say generate a JSON object like the following. So we want to have an object. In this object, we want to have a property called exercise which would be an object itself. Now inside this object, we want to have a property called benefits which would be an array. So we give it an example and then have it create this schema for us. All right, look. So here's the name of the schema, exercise schema. And here we have more details about this schema. So this is an object with these properties. In this object, we have a property called exercise, which is an object itself with these properties. In this object, we have a property called benefits, which is an array. Here's a description saying a list of benefits associated with the exercise. Next, we have information about the types of items in this object. So, each element or each item is a string. And here's a description of each element. Next, we have the validation properties like required. So, the benefits property is required. And no additional properties are allowed. Of course, we can always change this to fit our application. So, let's go with the schema. Save. Now we repeat the last prompt and say give me three benefits of exercising as a JSON object. All right, take a look. This time the model generated a different kind of JSON object. So we have this object with a property called exercise which is an benefits which is an array of strings. So that's text format. Now let's go back to plain text. Then we have temperature. With this we can control how random or creative the response can be. This is a value between 0 and 2. But we never set it to extreme values. Here's a guideline. We use a low temperature like 2 to4 for logical precise answers like summarization, answering factual questions and so on. We use higher temperature like a value between 7 to 1. 0 zero for creative expressive tasks like brainstorming, writing, marketing, copy and so on. So it's best to stick to this range. Don't go with extreme values like zero or two because the model can go crazy. Let me show you an example. So here we have set the temperature to two. Now let's say write a story about a robot. Look what happens. The model starts generating a story about a robot. But as we progress, look, it's generating gibberish. There is nonsense coming out because the model is getting extremely creative. This is the problem with really high temperatures. So, a good rule of thumb is to set it to 7. This is a good balance between logical and creative responses. Next, we have max tokens and this sets the maximum length of the response. Now, the value we set here really depends on what problem we're trying to solve. If you want to generate something short like a tweet, we have to go with a lower value. Otherwise, we may waste our tokens and pay more than we need. But when using lower values, keep in mind that the response can be cut off mids sentence. Let me show you. So, I'm going to set max tokens to 50. Now, once again, we're going to say write a story about a robot. All right. So, it says in the quiet town of Maplewood, there was a small robot named Pixel. Pixel was built by a kind inventor, Mrs. Rivera, who wanted to help. But look, the sentence is cut off. So to prevent this, we have to be more precise with our prompt. This is one of the prompt engineering techniques. There are many more techniques we'll cover later in the course. So here we can say, write a story about a robot in 50 tokens or less. Write a complete answer without cutting off mid sentence. All right. Now, with this second story, look, it's saying, "From that day on, Orbit became the twin's favorite friend. Always helping those in need. Robot heart glowing with happiness. " Beautiful. So, that was Max tokens. We also have top P. This is another way to control randomness, but it works a bit differently than temperature. Let's say as part of generating the response, the current token is ant. And here are all the possible options for the next word. Now next to each token you can see its probability based on the training data. If we set top P to one, we tell the model to use the full range of possibilities. So any of these words or any of these tokens can be used to generate the response. But if we use a lower top P like. 3 that makes the model focus only on the most likely next words. So once again, top is another way to control randomness. It works differently from temperature. In practice, we usually change one or the other, but not both. If you're not sure which one to use, stick with temperature and set top P to one. So these are all the common settings. We also have store logs which is used for logging and debugging. By default, it's enabled which means all our prompts will be logged on OpenAIS servers. To see them, we go to the dashboard. Then logs. On this page, you can see all the prompts we have sent to OpenAI. We have the input, the output, the model, and the date time. If we click any of these items, we can see more details. So up here we can click details. We can see the exact day time as well as the ID of the response. So each response has a unique identifier and this is useful for creating the conversation state. So the model remembers our previous responses. We'll talk about this later in the course. We can also see the model that was used, the number of tokens, the response format, max output tokens, temperature, and so on.
Calling Models
All right. Now, let me show you how we can call models in code. To do that, first you have to create an API key. So, on the top, let's go to the settings page. Then, we go to API keys and create a new secret key. We give it a name. This could be the name of our application like my playground app. Next, we assign it to a project. We have the default project, but we can also create multiple projects and assign different keys to different projects. Now, let's create the key. Good. Let's copy this back to VS Code. We remove all the code here and declare a constant called OpenAI API key. Reset it to this key. Now I got to emphasize this is just for demonstration. In a real application, we should never store API keys in the source code because with that anyone who has access to your source code can use your API key and you'll be the one paying for it. So as a best practice, we should always store the keys outside of our source code using environment variables. We'll talk about that later when we start building projects. Next, we should install the OpenAI library. So we open a terminal window and run npm install open AI. All right, good. Now this library is just a wrapper around open AAI API. So it gives us a class with a bunch of methods that we use behind the scene. It's going to make HTTP calls to the API exposed by OpenAI. If you go to the docs and look at the libraries page, you can see that they also have official libraries or SDKs for JavaScript, Python, Net, Java, and Go. There are also a bunch of libraries built and maintained by community for other languages. So back to the code on the top, first we import the OpenAI class from the OpenAI module. Next, we create an instance of this class. So we declare a constant called client because this is a client to open AAI platform. We set it to a new instance of open AAI and provide an object and here we set API key to open AAI API key. Next we call client responses. create and give it an object. Here we can set the model to let's say GPT-4. 1. We set the input to our prompt like write a story about a robot. We can also set the other settings we talked about in the previous lesson like temperature. Let's set it to 7. And we set max output tokens to let's say 50. Now this method returns an API promise. So we have to await it to get the response. So let's await this and get the response. And finally we log the response on the console. Okay. Now back to the terminal. Let's run node index. js. Now the terminal freezes because we are waiting for the response to be generated. But later we'll enable streaming so you can see the response as it's being generated. Okay. Now here we have a bunch of properties in the response. The one we use most of the time is output text. This is the response generated by the model. But we also have a few other useful properties. We have usage where we can see the number of input and output tokens. Now in this case I'm not entirely sure why input tokens is zero but output tokens is 50. We also have top P which is set to one by default. We have temperature. We have store. So this response is logged on OpenAI servers. And also up here we have the unique identifier for this response. Later when we build a chatbot, we'll use this to maintain a conversation state. So the chatbot remembers the conversation history. Now back to the code, let's see how we can enable streaming. To do that, first we set stream to true. Now to see this clearly, let's increase max output tokens and set it to 250. Now when we set stream to true, we no longer get a response object. Instead, we get a stream object. So let's rename this by pressing F2 to stream. Now this stream is what we call an async iterable. What does it mean? Well, an iterable is an object that we can iterate over like an array. So here we use a for loop and say for const event of stream. So we are iterating over the stream getting one event at a time. Now I told you that this stream object is an async iterable. So to iterate over it we have to use the await keyword because these events are generated at runtime. So we don't get them all in one hit. We get them as they are being produced. Okay. So, we iterate over the stream object and then log the event on the console. Now, back to the terminal. Let's rerun our program. All right. Now, look, we're printing these event objects as we are receiving them. Let's scroll off and take a look at a few of them. So, look, each event has a type property. In this case, the type is response. output text. ta. This is the event that represents a chunk of text or a token being generated at runtime type. Each of these events has a sequence number. So this is 252. Next one is 253. Here we have a delta property that is the token that is being generated. So here's one token. Then we have another token. So to print the response in the terminal as it's being generated, we just have to print the delta property. So back to the code, let's print event. ta and rerun our program. So these are the tokens that are being generated. But the problem is at the beginning and at the end we get a bunch of undefined messages in the terminal. The reason for that is because not all events contain the delta property. The delta property only exists in events that contain a chunk of text being generated. But we have other events that represent the beginning and the end of this operation. So the proper way to do this is by checking if event delta is defined then we print it on the terminal. So let's rerun our program. Okay, we're getting this tokens. No more undefined messages. Beautiful. But we don't want to print each token on a new line. The reason this happens is because console. log log always adds a new line character at the end. So to print these tokens next to each other, we have to take a different approach. We have to call process std out which represents the standard output or the console window. This object has a right method. We call this and pass the delta. Now let's rerun our program. Okay, this is the same experience we have when we use chat GPT. So that brings us to the end of this section. In the next section, we'll start setting up a modern full stack project. So, I will see you in the next section.
Setting Up a Modern Full-Stack Project
Welcome back. Before we start building, we need to set up a clean, modern, full stack project. One we'll use as the foundation for everything in this course. Now, if you have done any research, you have probably seen tons of templates and GitHub starters for full stack applications. And while some of them are great, I decided not to use any of them. I didn't want this course to depend on someone else's setup or introduce tools we haven't covered. Also, we will not be using Nex. js. In case you don't know, it's a powerful full stack framework built on top of React, but not everybody likes it and it comes with its own learning curve. I didn't want this to be a prerequisite for this course. So, instead, we'll set everything up from scratch using tools like Bun, Vit, and Express. This gives us full control. no hidden magic and a setup that's easy to understand and easy to scale. Along the way, we'll add Tailwind for styling, set up SHA CN UI for components, format our code with prettier, and automate our workflow with Husky. By the end of this section, we'll have a solid full stack foundation that's lightweight, clean, and fully ours. Now, let's jump in and get started.
Setting Up Bun
Before we start creating our project, there is a tool I want to introduce that we'll use throughout the course and that's bun. If you haven't heard of it before, bun is a modern JavaScript runtime kind of like Node. js but faster and more integrated. With Node, we typically rely on multiple tools. We use npm to install packages, TS Node to run TypeScript, and something like Nodemon to restart the server when we make changes. With Spun, we get all of that in one tool. It's a runtime, a package manager, a taskr runner, and even a TypeScript transpiler allin one. So, it can run TypeScript files out of the box. And we don't need to install a bunch of extra tools just to get started. Now, if you're more comfortable using Node, that's totally fine. Everything I'm going to show you can be done with Node as well. But if you follow along with Bon, you will probably find the experience cleaner and honestly, a lot more enjoyable. So, head over to bond. sh. And here on the homepage, find the installation instruction for your operating system. Just copy this command and run it in a terminal window. Now follow the instructions in the terminal. So here on Mac, we have to execute this command. Exec/bin/zsh. Now to verify that bon is installed properly, we run bon- version. So on this machine, I'm running bun version 1. 2. 17. In the next lesson, we'll talk about our project structure.
Creating the Project Structure
Now, to create our project structure, we open a terminal window. We go to somewhere on our machine. I'm going to go to my desktop. Next, we create a directory. Let's call it my app or whatever you want. We cd into this directory and run bun in it. This is the same as npm in it. So, it creates a package. json JSON file as well as some additional files. Let's go ahead. First, we answer this question to select a project template. We have blank, react, and library. Let's select blank. So, this created a few files. We have a git ignore file, a rule file for the cursor editor, an index file, a TypeScript configuration file, and a readme file. It also installed TypeScript. So, now let's open this with VS Code. So, here's what we get. We have a directory for the cursor editor. I'm not using cursor, so it's safe to delete this. We have our node modules directory, a git ignore file, a bond lock file, an index file, which is just a console lock statement, our package json file just like a node project, a readmi file, and a typescript configuration file. Now to set up a full stack project, we're going to use something called a workspace, which is a feature built into bun that lets us manage multiple sub projects like a client and a server application from a single place. It's also available in node projects. So by convention, we put our sub projects inside a directory called packages. So here we add a directory called packages and then we add a subdirectory for our client app and one more for our server app. Next we go to our package. json file and declare our workspaces. So here we add a new property called workspaces and set it to an array of strings. Here we type the path to our sub packages. So we go to the packages directory and grab the client directory and one more for the server directory. Now there's a shorthand syntax here. We can replace client with an asterisk to say all directories under packages should be treated as workspaces. So let's delete the second entry. This is good. So our project structure is ready. Over the next few lessons, we'll create the client and server applications independently. Now I also want to initialize a git repository here. So let's open a terminal window and run git in it. Good. And make our first commit. Initial commit. Next we'll create our backend project.
Creating the Backend
Now to create our server application here in the terminal we go to the packages/server directory and run boninit one more time to create the sub project in this directory. We select blank. Now back to our project here in the server directory. We have the same files you saw in the previous lesson. We don't need the cursor directory. So let's get rid of it and clean up our project. Now back to the terminal. Next we shall install express as our web server. In node projects we run npm install or npmi. In bun projects we run bun at. So with node projects we have two different tools. Node for running our code and npm for installing dependencies. But in bun projects all these features are integrated into bun. So we run bun at express. Okay. We should also install express types for TypeScript as a development dependency. To do that, we run bun at-d types/express. Good. Now back to our project. Look here in the server directory, we have this package. json file. And in this file, we have express as a dependency and also express types as a development dependency. So in this project so far we have two separate package. json files one in the root directory and one inside the server directory. Later when we create the client project we'll also have a package. json file in our client directory. Okay. Now what is interesting about this structure is that in this setup we don't have different node modules in our server and client applications. So we don't have a node modules directory inside the server directory. We only have one at the top level where we have all the client and server dependencies. So right now we have express as well as all its dependencies installed in this directory. Okay. So we have installed express. Now let's create a basic web server. So we go to the server directory and open index. ts. Here on the top first we import the express function from the express module. We call this function and get an object which we call app. Next, we declare a constant called port. We can initialize it from an environment variable. To do that, we use process. n. port. So, if we pass port as an environment variable, we can pick it up here. This is useful in production environments. But otherwise, if this environment variable is not defined, we can give this a default value of 3,000. Next we define a route. So here we call app. get and give it two arguments. A path like a forward slash which represents the root of our web server and a function that gets executed when we receive a request at this endpoint. This function should have two arguments a request and a response. Here we use a lambda function and in this function we just want to send the hello world message to the client. So we call response send and pass hello world. Okay. Now so far we have been writing plain JavaScript code. There is no TypeScript here. But we can annotate these arguments with types. So on the top we can import the request and response types from the express module. And then we can annotate these parameters with request and response. Okay. So we have defined a route and a route handler. Next we should start our web server. So we call app. listen and give it two arguments a port and a callback function that gets executed when the web server is up and running. So again we use a lambda function. And here we can say console. log. Now I'm going to replace single quotes with back tick. So we can use template literal. So here we can say server is running on http localhost port. Now here we use a template literal. So we add a dollar sign and curly braces to insert port dynamically. Okay. So that's pretty much it. Now to run this, we go back to the terminal and here in the server directory, we run bun run index. ts. Okay, our server is running. So if you hold down command on Mac or control on Windows and click this, it opens our web browser. So our web server is set up properly. Beautiful. Now instead of running this command every time, we can define a custom command like on start just like we do with node projects. So let's stop this process by pressing control and C. Now we should go to our package. json file. Here in VS Code, we can hold down command on Mac or control on Windows and press P to quickly find files. If we type package. json, you can see we have two files, one in the root directory and one in the server directory. So here in the server package. json, we define our custom scripts. So we add the scripts property. Here's our first script. We call it start and set it to bun run index. ts. We also define a custom script called dev for running our application in watch mode. So anytime we make changes to our files, bun will automatically restart our web server. We set this to bun run index. ts just like before, but here we use the watch option. Make sure to add this right after bun and before run. Okay, now back in the terminal, let's test our commands. So, first we run bun start because this is a built-in command. Okay, our web server is running again. Let's verify it. Beautiful. Let's stop this and try the other command. Now, dev is a custom command. So, we cannot run bundev. Instead, we should run bun rundev. Now, bun is watching our files. So if we go to the index ts in the server directory and make a small change, let's remove the exclamation mark. This should restart our web server. So if we go back to the browser and refresh, the exclamation is gone.
Managing OpenAI API Key
Earlier in the course, I told you that we shouldn't store API keys in the source code. For example, here we don't want to declare a constant like API key and set it to whatever because with this anyone who has access to our source code can use this API key and we'll be the one paying for it. So the right way to manage API keys is using environment variables. That's what I'm going to show you in this lesson. So let's go back to our terminal window and stop this process. Now if you're on a Mac or Linux, you can use the export command. If you're on Windows, you should use the set command. With these commands, we can set an environment variable which is a variable stored at the operating system level. So we give it a name like open AAI underline API underline key. Now by convention, we use capital letters for environment variables. So we set this to a value like 1 2 3 4. Now back to index. ts. Let's remove this line from here. In this route handler, I want to temporarily return our API key. To do that, we use the process object. We go to env or and access open AAI underline API underline key. Make sure to spell it properly. Now back to the terminal. Let's restart our application and go to the homepage. Refresh. This verifies that we could successfully read the environment variable in our application. Beautiful. But there's a problem with this approach. With this approach, every time we want to start our application, first we have to set our environment variables. And this is very tedious. So this is where we use a library called env to streamline this process. Let me show you how to do that. First we stop this process. Next here in the server package we install env oren. Next we go to our project and here in the server directory we add a new file called env. Now if you look at this git ignore file you can see that env files are by default excluded from our git repository. So in the future when we push this repository to github the variables that we declare here will not be exposed to the public. So this is for our private use here we can set open ai underline api underline key to let's use a different value like abcd to differentiate from the former value. Now to load this in our code, we go to index. ts on the top. First we import object from env module. Then we call env. config. This should be the first line in our module. What this does is it goes in this file. It reads all the variables we have declared here and stores them as environment variables before running our application. You will see that in a second. So back to the terminal, let's restart our application. All right, look. Env is injecting environment variables from our env file. So back to the browser. Let's refresh, but the value has not changed. What's going on? Well, the variable that we declared earlier in the terminal window overwrites the variables that we have declared in our env file. To fix this issue, we have to remove the environment variable we set earlier. So, let's stop this. If you're on Mac or Linux, use onset followed by the name of the environment variable like Open AI underline API key. If you're on Windows, use the set command and set the environment variable to an empty value. Okay, now let's restart our application and refresh the homepage. Okay, here's the updated value. Beautiful. So now I'm going to replace this with my actual API key. So back to our env file, I'm going to replace ABCD with a real API key. Beautiful. Now there is a problem here. The problem is that if we commit our code to a Git repository, someone else cloning this has no idea what environment variables they should set. So to help them here, we duplicate this file and rename it to env. example. Example, this file is not going to be excluded from our Git repository so others can see what variables they should set. In this file, we keep the variable names, but we remove the actual values. Now, the last part, back to index. ts. Let's revert this code back and return hello world to the client. So, we're done setting up our API key. Now the final step we make a commit and say manage open AAI API key. Now one more thing before we finish this lesson. If you make any changes in this env file you have to restart the application. So bund will not detect the changes here which means you have to go to the terminal stop this process and restart the application. So the new values are injected into the environment variables. Next we're going to create our client project.
Creating the Frontend
Now to create our front end application, we're going to use Vit. Vit is a very popular build tool for front-end applications. You have probably seen it before. So here on vit. dev, let's go to get started. On this page, you can see the command for creating a new vit project. So with npm, we run npm create vit at latest. We also have support for bun. Let's copy this command. Now back to VS Code. Let's open a new terminal window. So in this application, we're going to have two terminal windows open. One for the server, one for the client. Now we can rename this for clarity. So I'm going to rename this to server. And the second terminal window to client. We can also color code them if you want. Change color. Let's make the server green and the client yellow is fine. Now let's go to the packages/client directory. Paste that command but don't execute it yet because if you do so vit will create a subdirectory here which is not what we want. So here we add a period which means create the front end application in the current directory. Let's go ahead. First we select our framework which is going to be react. Next we select a variant. We're going to go with TypeScript. Okay. Now, back to our project. Look here in the client directory. We have a typical React project created with Vit. There is nothing magical here. In this directory, we also have a package. json file. So, currently we have three package JSON files. One at the root, one for our server application, and the other for the client application. Now, back to the terminal. The next step is to install dependencies using bun. So we can run bon install or bon i. This installs all the dependencies inside our top level node modules directory. So once again we're not going to have different node modules in client and server applications. All right. Now that the dependencies are installed, we can run our application by running bun rundev. So dev is a custom script that is defined in our client package. json file. Let's review that package. json JSON in the client directory. Look, so here we have these scripts, dev, build, lint, and so on. So let's run our client application. Beautiful. Now let's make sure it's working properly. All right, here's our React project. Lovely. So let's wrap up this lesson by making a commit. Create the front end.
Connecting the Frontend and Backend
All right. Now, to connect our client and server applications, we're going to go to our server application and define a new endpoint. So, we press command np on Mac or control np on Windows and go to index. ts, the one in the server directory. Now, we're going to grab this route handler and duplicate it. Now, let me show you a cool shortcut on the top under the selection menu. Look, we have this command copy line down. The shortcut on Mac is option shift and down. So with this line selected, if I hold down shift, option, and press down, these few lines get duplicated. Now I'm going to change the path to / API/hello. And instead of returning plain text, I want to return a JSON object. So let's pass an object and give it a property like message and set it to hello world. Now before going further, let's test this. So back to the browser here on our server application. Let's send a request to / API/hello. Okay, this is our JSON object. Beautiful. Now if this is not pretty formatted on your machine, just install this Chrome extension JSON formatter. Now let's move on to the client part. So we're going to go to app. tsx. This is the container for our client application. Now, we're going to delete all the import statements. We should also delete all the code inside this function. But we are not going to manually select these lines. I'm going to show you another cool shortcut. So, we put the cursor on the first line. Now, on the top under the selection menu, look at the shortcut for this command. Expand selection. On Mac, it's control shift command and right. So, with the cursor here, I'm going to hold down shift control and command. Now if I press the right arrow the selection will expand. If I press the left arrow the selection will shrink. Take a look. So I press the right arrow. Now this word is selected. I press the right arrow again. Now the entire line is selected. Let's keep going. Now the entire body of this function is selected but not the curly braces. We can keep going. Now the braces are selected. If we keep going the entire function definition is selected. Obviously that's not what we want. So I'm still holding shift control and command with my left hand. Now if we press the left arrow, we can shrink the selection and delete the code in this function. Now here we're going to declare a state variable. So we use the state hook, initialize it to an empty string, and call it message. Now we're going to write a very basic React code to make an API call. So we use the effect hook. Now I know some people are going to have a heart attack saying this but this is just for a quick demo. There are better ways to make API calls. We'll look at that later in the course. So here we use the fetch function to send a request to / API/hello. Then when the promise is resolved we get the response. We convert it to a JSON object and then we get the data and here we set the message to data message. Now as the second argument to the effect hook, we pass an empty array as our dependencies. So this code is executed only once. This is just basic React stuff. You should be familiar with this concept. And finally, we return a paragraph where we render our message. Now if you run our application, this is going to fail because the API endpoint doesn't exist in our client application. So if you go to our client application and send a request to / API/hello, obviously it's not going to work. This is only available in our server application. So to solve this issue, we're going to set up a proxy to automatically forward all requests starting with /appi to our server application. To do that, we go to vit. config. ts. In this object, we add a server property. Next, we set proxy to an object. And here we map requests from / API to HTTP localhost port 3000. And that means if you send a request to let's say / API/hello, this will be automatically forwarded to localhost port 3000/ API/hello. Okay, so with that in place, let's test our application. Back to the browser. Let's go to our client application. Refresh. There you go. We have the hello world message, but it's displayed in the center of the screen, which is not what we want. So, back to VS Code. Let's go to index. css and delete all these styles. We'll come back and work on styling in the future. So, back to the browser. All right, looking good. So, let's wrap up this lesson by making a commit. And here we say connect the front end and back end.
Running Both Apps Together
With our current setup, every time we want to start this application, we have to open two terminal windows. One to start the server using one rundev and the other to start the client using the same command. This is tedious. So in this lesson, I'm going to show you a simple way to start both applications using a single command. First, we stop the client and the server. Now we open a new terminal window. Make sure this is pointing to the root of the project. Here we install a dependency as a development dependency called concurrently. Make sure to spell it properly. With this library, we can start multiple applications using a single command. To do this, we go to index. ts in the root directory. Now, currently, we have a console lock statement. Let's get rid of this. Instead, we import the concurrently function from the concurrently module. We call this function and give it an array of commands. Each command is for starting one application. So, here's one object here. We should set a few properties. The first one is name. We set this to server. Next is command. bun rundev. This is the command that we run. We're starting our server application. Right. Next, we set cwd or current working directory to packages/server. So, we want to run this command from this directory. Now optionally we can assign a prefix color here because with this approach we're going to have a single terminal window. So to differentiate between client and server messages we can assign them different colors. So for server we can use cyan. Now let's duplicate this again. We put the cursor somewhere here. Hold down shift control command and press right to expand selection. Now the entire object is selected. Now let's duplicate it with shift option and down. Good. Now we add a comma here and make a few changes. So this one is going to be client. Again we run our client application using the same command but from a different directory. Also let's change the color to green. Good. So with this setup to start both applications we should run index. ts from our root directory. Now to simplify things, we're going to go to the package. json file in the root directory and define a custom script here. So scripts, we can define a dev script and set it to bun run index. ts. Now back to the terminal. So we're in the root directory here. We run bun rundev. This started both applications. You can see server messages are in cyan and client messages are in green. Let's make sure our setup is working. So back to the browser, let's refresh. Beautiful. So with this, we no longer need the server and client terminal windows. Let's clean things up and make a commit to wrap up this lesson. Run both apps together.
Setting Up TailwindCSS
together. All right, let's talk about styling our application. To style our application, we're going to use Tailwind CSS. Now, if you haven't used Tailwind before, it's a utility first CSS framework, which means it gives us a bunch of small descriptive CSS classes like flex, PT4, which is short for padding, top four, text center, and so on. So, we can use these classes in our markup to style our elements. With this, all the styling is here. So, we don't go back and forth between a CSS file and a CSS file. Now, some people love this, others not so much. But in this course, our focus isn't on styling, it's on building AI powered features. So, we'll keep styling to minimum. And we'll use Tailwind because it's a very popular framework. And if you're not familiar with it, I highly recommend to learn it because it's something that comes up in job descriptions a lot. So, head over to tailwinds. com. Now let's go to the documentation and follow the installation instructions. So we are using Vit. Let's see what we have to do. First we have to create our project which we have done so far. Next to install Tailwind we have to install two libraries. Now we're not going to use npm. So let's grab these two libraries. Back to VS Code. Let's open a new terminal window and go to packages/client. Next, we run bon add and paste these two libraries. All right, good. Now, back to the documentation. The next step is configuring the vit plugin. So, we're going to go to vit. config. ts, import tailwind CSS on the top, and add it in the list of plugins. So, let's grab this line, copy it, and go to vit. config. ts. We paste it here and then add it in the list of plugins. This is a function so we should call it. Okay. Next, we should import tailwind CSS in our root CSS file. So, let's grab this line and go to index. css. Paste it here. I believe this is the last step. So, that's it. Now we can start our application and start building. So let's go to app. tsx and style this message. Here we set class name. Let's set it to font bold. And by the way, I highly recommend to install Tailwind extension in VS Code to get auto completion here. So here on the extensions panel, search for Tailwind CSS. Okay, this is the extension I'm using. Tailwind CSS IntelliSense 10 million downloads. All right. So, let's try font bold. Here's what we get. That looks good. Now, let's give it some padding. So, we can add in a padding of four, which is equivalent to padding of one ram. So, this utility class is a container for this st padding of one ram, which is 16 pixels. Now, take a look. We have some padding around the text. We can also make the text larger. Let's try text 3x large. Okay, that's better. So, that was the basics of Tailwind. As we go through the course, I'm going to show you some additional features. So, let's wrap up this lesson by making a commit and say setup Tailwind CSS.
Setting Up ShadCN/UI
All right, we set up Tailwind. Now, we're going to set up a UI component library to speed things up, and that's Shatzen. In case you haven't used it before, it's a collection of beautifully designed, accessible, and customizable components. Here on their homepage at ui. shhatsen. com, you can see various examples. They have all these beautiful modern customizable components that we can easily add to our projects. So, let's follow the documentation. Let's go to the docs. First we select our framework which is vit. Now we have created our project. So let's move on. Next we should add tailwind because chaten is built with tailwind. We can move on from this step. We did it in the previous lesson. We also imported tailwind in our index. css file. So now we should modify our typescript configuration file. Now in the current version of vit projects we have three typescript configuration files. We have to modify two of them. One of them is tsconfig. json. JSON. In this file, we should add the compiler options. So, let's select these few lines. Copy. Now, back to VS Code. Here in the client directory, look, we have three TypeScript configuration files. Here's the base one with base settings that is shared between the others. We have one for our React application or front- end stuff and one for Node, which is used for tooling. So, first let's go to tsconfig. json. and paste compiler options. Now back to the documentation. Next, we should modify tsconfig. app. json which is used by our react app. In this file, we already have the compiler options property. So we should only select base URL and paths. Let's copy these. Now let's go to tsconfig. app. json. So here's the compiler options. We just paste these two properties on the top. Back to the documentation. Let's move on. Next, we should update our V configuration file. First, we have to install node types. So, let's copy this line. Back to our terminal. Make sure you're in the client directory. Paste it. Okay. Now, back to the docs. In our V config file, first we have to import path. We should also import tailwind CSS which we did in the previous lesson. So let's just grab the first import statement. Copy it and go to vit config. ts. We paste it on the top. Back to the docs. Now while configuring vit we should add these two plugins react and tailwind which we did before. We just have to add the resolve property. So copy this. And I like to order these alphabetically. So after plugins, I add Resolve. Okay, we're almost there. Now back to the docs. With this, we can run the Shatian CLI. With this CLI, we can easily add components to our project. So we're going to use BONX, which is like npx for running packages. Let's copy this line. Back to the terminal again. Paste it. All right. Chaten is asking what color we want to use as our base color. We have a few options. Neutral, gray, zinc, stone, and so on. What is the difference? Well, back to their website. On the top, let's go to the colors page. Look, this is an example of neutral colors. Then we have stone, which has a warmer tone. We have zinc, which has a cooler tone, slate, gray, and so on. So, that is the difference. I'm going to go with neutral. Now, this CLI created a file called components. json, which keeps track of the components we have installed. Let me show you components. json. It's just some internal stuff. We're not going to modify this. This is used internally by the CLI. Later, when we install components, those components will be listed here. Okay. Now, this CLI also modified our index. css file. Let's take a quick look. index CSS. So in the previous lesson, we only added Tailwind CSS. Now we have a bunch of additional stuff for our theme. So all the base variables for our colors, padding stuff, they're all defined in our CSS file and we can always modify this in the future. Okay, so back to the documentation. Let's move on to the next step. Now you're ready to install components. So if you go to the components page, you can see all the components. In this lesson, I'm going to add a button, you can see a preview up here. You can see an example in the code. Now on the same page, you can see the installation instructions. So we use BONX again to use Shatn CLI to add this button. Let's copy this line and run it in the terminal. Great. Now this button component is part of our project. Take a look. We have all the source code here. We can make any changes we want. All these classes are Tailwind classes. So, we can customize this button to achieve the look and feel we're looking for. Okay. Now, let's see how we can use this. So, we go to app. tsx. First, let's wrap this expression in parenthesis so we can break it down into multiple lines. Now, below the paragraph, I want to add a button. So, we add a button component. This is defined in components/ UI/button. Let's import it. We give it the text like click me. Now, because we have multiple elements, we have to wrap them inside a root element. Okay, back to the browser. Here's what we get. Beautiful. But the button is too close to the edge of the screen. So let's convert this to a div and give it a class of P4. Now we can remove P4 from our text. Now take a look. Okay, that's better. So let's wrap up this lesson. We make a commit and say setup shaden UI.
Formatting Code With Prettier
In this lesson, we're going to set up Prettier. Prettier is a tool that automatically formats our code, so we don't have to think about things like spacing, indentation, or semicolons. That means fewer distractions, fewer style debates, and code that's easier to read for us or anyone else working on this project. So, there are a number of steps you have to follow. Pay close attention to what I'm doing, even if you have set up prettier before. First, we go to the extensions panel and find the prettier extension. If you haven't installed it, go ahead and install it. Next, we're going to define our styling rules. Now, by default, Predier comes with its own rules, but we can override them by creating a prettier rc file. So, here in the root of our project, we add a file called prettier rc. Make sure to spell it properly. Here we add a JSON object where we define our formatting rules. Now, there are a number of settings you can customize. You can look at the prettier documentation, but at a minimum, you want to set single quote to true. This is my personal preference. I prefer single quotes to double quotes. And by that, I'm talking about JavaScript code, not JSON because in JSON, we cannot have single quotes like this. Okay? So, we're going to use single quotes in this project. Next, we set semi to true. That will terminate our lines with a semicolon. Again, my personal preference. Next, we set trading comma to ES5. This setting controls whether or not Prettier adds a comma at the end of the last item in things like arrays, objects, or function arguments when they are written on multiple lines. Now, if we set this to ES5, this tells Priier to add trailing commas where valid in ES5. That means objects, arrays, functional arguments, but not in function definitions or inline arrow functions. Okay. The next setting we're going to set is print width. The default is 80. We keep that. And also tab width, which is the number of spaces for indentation. The default is two. I'm going to change it to three. Now, to see this in action, let's go to app. tsx. Now, in this file, we're using single quotes. I'm going to change these single quotes to double and also remove the semicolon. Now, we're going to go to the command pallet. We can find it under the view menu. The shortcut is shift command and P on Mac. Here search for format document. Now the first time you execute this command, VS code will ask you about the default formatter. Select prettier. Once you do that, format your code. Now look, the double quotes are replaced with single quotes. We have a semicolon and we have consistent tab width. Now we can also configure VS Code to automatically format our code whenever we save our files. To do that we go to the settings. So settings the shortcut is command and comma on Mac. Here search for format on save. Make sure it's enabled with this. If you remove this semicolon but save this file prettier automatically formats this code. Now we can also format our code from the command line. And this is useful before committing our code to git and sharing it with others. To do that, first we have to install Predier as a development dependency. So open a terminal window, a new terminal window pointing to the root of our project. Let's run bon add-er. We add this to the root of our project because we don't want this to be specific to the client or the server application. Okay, let's install this. Next, we go to the package. json JSON file in the root of our project. In this file, we have a script or a command for running both applications. Next, we're going to define a command for formatting the entire codebase. We set this to prettier d-right period, which means start from the current directory. Now, as part of formatting our files, we don't want to format third party code stored in the node modules directory. So here we're going to add a new file in the root of our project called predier ignore. This is similar to git ignore. In this file we can list all the files or directories that should be ignored by predier. So at a minimum we add node modules and also it's good to add bun. lock. This is a lock file used by bun for installing dependencies. We should never touch this. With this in place, now we go back to the terminal in the root of our project and run bun run format. All right. So this formatted all the files in this project. With this we can wrap up this lesson and say setup prettier.
Automating Pre-Commit Checks With Husky
In the last lesson, I showed you how we can format our code before committing it to Git. And I told you that this is a good practice to follow before committing our code to Git and sharing it with others. But there's a problem. What if we forget to run this command before we make a commit? This is where Husky comes in. With Husky, we can automate our Git workflow. So, we can run certain commands like formatting our code or running tests before committing or pushing our code. To get started, go to typicode. github. io. io/husky. Now let's go to the get started page. First we have to install husky as a development dependency. So let's copy this command back to the VS code. Open a terminal window pointing to the root of the project. Let's run this command. Good. Back to the documentation. Next we have to initialize husky. What this does is it creates a pre-comit script in the husky directory. I'll show you that in a second. So let's run bonex husky in it in the terminal window. Good. Now back to our project. So here we have the husky directory. In this directory we have this pre-commit script. In this file we can add any commands that should be executed before committing our code. By default it tries to run one test for running our tests because this is a good practice to follow. But in this project we don't have any tests. So instead we should run bun run format. But there's a problem here with this command we'll format our entire codebase. And that means as our project gets larger as we add more files this operation is going to get slower. But there's a second problem. If we have worked on a certain feature and modified only let's say two files this command will potentially format other files that are not formatted and we'll put them in our commit which can be misleading. So later when we look at our git history, if you open a commit, we'll see a bunch of files that are modified just as a result of formatting. So instead of formatting the entire codebase, we should format only the staged files. To do that, we're going to use a separate library called lint staged. With this library, we can execute tasks on staged files. So back to the terminal, let's install lintstaged as a development dependency. lint dash staged. All right, good. Now, back to our pre-commit script. We're going to replace this command with bonex lint dash staged. So, when we're going to make a commit, we'll run lint staged. Next, we should tell lintstaged what task to perform on what files. To do that, we go in the root of our project and add a new file called lintstagedrc. Make sure to spell it properly. In this file, we add a JSON object where keys are file patterns like we can say any file with any name but with one of these extensions. So in braces we add JS, JSX, TS, TSX and CSS. Now for the value we specify the command that should be executed for stage files that match this pattern. In this case we're going to run prettier d-right. Now earlier we added a period but we're not going to use that here because this will format the entire codebase starting from the current directory but in this case we only want to write or format files that match this pattern. Okay so to recap next time we're going to commit husky will run the pre-commit script in this script we're running lint staged lint stage will look at this file it figures out that it should run prettier on files that match this pattern. Okay. Now, to see this in action, let's go to app. tsx and make a few changes. First, I'm going to add an exclamation mark here, but I'm going to also mess up with formatting and also remove this semicolon. Now, I'm not going to save this file because if I do so, Predator will automatically format this file. In my VS Code, I have set up autosaving. So under file menu we have autosave enabled which means the file is saved but it's not automatically formatted. Formatting only happens when we explicitly save this file. Okay. Now let's go and make a commit. So here we can say setup husky. Now there is a problem at the time of this recording. I believe this is a temporary issue with VS Code. Hopefully that doesn't happen when you're watching this video or maybe this is a problem with my setup. So when I make a commit, look, we get this error saying bonex command not found. Here's what's happening. So VS code is complaining that the bonex command that we have referenced in our pre-commit script cannot be found. Now this doesn't happen if we make a commit from the terminal window. So one way to solve this is by adding all the files here and then making a commit. Let's say setup husky. Okay, the commit is done and now we can verify that our app tsx is formatted. So let's go to app. tsx. Look, the file is beautifully formatted and we have this semicolon here. But what if this happens on your machine and you don't want to commit from the terminal? The solution for that is to replace bonex with npx. So we go to our pre-commit script and replace bonex with npx. I know this is not ideal. is kind of like a hack because we decided to use bun for the entire project. But if you really like the source control panel in VS Code and prefer to make commits this way, you have to replace bonex with npx. Let's make sure this works. So back to appsx again, let's make a change here. Remove the semicolon and mess up with formatting. Let's make a commit. I'm going to say test husky. Okay, no problem. But I'm not going to keep this commit in our history. So back to the terminal. Let's run git log d- one line. So here's our commit history. Now the head pointer is pointing to this commit test husky. We want to get rid of it and have the head point to this previous commit. So the way we do that is by running get reset d- hard. Then we get the head pointer and go one step back. Let's go ahead. Good. Now let's verify that everything looks good. Get log one line. Now the head pointer is pointing to this commit. Beautiful. So we're done with this section. In the next section, we'll start building our first project.
Project: Building a ChatBot
Chatbots are everywhere now. They're becoming a core part of modern applications. So in this section, we're going to build one together from scratch. At first glance, it looks simple. A text box, a send button, and a list of messages. But behind the scenes, there's a lot going on. There's subtle UX details, state management challenges, and edge cases that are easy to overlook if you haven't built one before. So, grab yourself a cup of coffee and let's get started.
Building the Backend
Now, this section is a little bit longer, so I've broken it down into two segments. In this segment, we'll start by building the backend for chatbot. First, we'll create a basic API that receives a message and returns a response from an AI model. Once that's working, we'll gradually improve it by adding input validation, error handling, and making sure it's robust and clean. Then, we'll reorganize our code to keep things modular and easy to maintain. By the end of this segment, we'll have a fully functional production ready backend ready to plug into our front end. So, let's jump in.
Building the Chat API
In this lesson, we're going to build a simple API endpoint that receives a message from the user and returns a response. To get started, first we open a new terminal window and go to packages/server and install OpenAI. So, one addi. Good. Next, we go to index. ts in our server application. on the top. First we import open AAI from OpenAI. Once we run config, then we create a new instance of OpenAI with our API key. So let's declare a constant called client and set it to new OpenAI. Here we set API key to we go to process environment and grab open AI underline API underline key. Okay. Next, we define a new endpoint for receiving prompts from the user. So, we call app. post. Now, in this case, we're not going to use that get method because we're not just getting information, we're submitting data to the server. So, we have to send an HTTP post request to this endpoint. Now, for the path, let's go with API/ chat. Next, we add a request handler. So request and response. Now in this function first we should grab the user's prompt from the request. So this request object has a body property. Let's say in the object that we sent to the server we have a property like prompt. We get that and store it in a constant. A cleaner way is to use dstructuring. So instead of accessing the prompt property, we grab request body and dstructure it to grab the prompt property. That's cleaner. Next, we send this to OpenAI. So we call client responses. create. We pass an object. First, we set the model. Now what model are we going to use? Back to the OpenAI website. Look here on the models page. We have different categories of models. We have reasoning models which are used for solving complex multi-step tasks like coding problems. We have another category flagship chat models. They're highly intelligent chat models. Then we have costop optimized models. These are smaller and faster. We also have research models, runtime models, image generation models, and so on. For a chatbot, we can go with one of these costs optimized models because we want a small model that can quickly respond to the user's queries. We don't need reasoning. We don't need to solve complex problems. So for comparison, let's compare a couple of these in terms of their performance and price. I'm going to compare 04 Mini with GPT 40 Mini. So on the top, let's compare models. Here we have 04 Mini. Let's also compare GPT-40 Mini. Now Mini has various flavors. We have mini audio, realtime, and so on. We're going to grab this base model. Now compare these two models. So for OMI is the fastest of all these models. It's a multimodal model. So in the input we can pass text and image. Now compare the prices for this model. The price of 1 million input tokens is 15 cents. Now compare that to this other model. It's 10 times more. So it makes more sense to use this model for our chat application. Now, what about context window? The context window of this model is 128,000 compared to this other model. But for a chatbot, let's say this is going to be a customer assistant. Usually, we don't have long conversations. We don't need a very large context window. Users come ask a few questions and move on. So, I believe 128,000 tokens is a good size for the context window. So, back to the code. Let's set the model to GPT-40- mini. Next, we set input to users prompt. It's good to set a temperature. Now, in the chatbot, responses should be accurate and consistent. We don't need creativity here. So, we should stick with lower temperatures somewhere between 2 to 4. I'm going to go with 02, but we can always modify this in the future. There's no hard and fast rule. This is more an art than science. We have to test different temperatures to see what kind of responses we like more. Now, it's also good to set max output tokens, otherwise the responses are going to be long. But in a chat application for a chatbot, we should have relatively short responses. So, I'm going to set this to 100 tokens. And again, we can always come back and adjust this. So, we call this method. We await the call and get the response. Because we are using await, we have to mark this function as async. So, we have the response. The final step is to return a JSON object to the client. So we call response. json. And here we add a property like message. We set it to response output text. We're almost done. There's just one step missing. Look in this function. We're extracting the prompt from the request body. By default, this is not going to work unless we tell Express to automatically parse JSON object from the request body. The way we do that is by adding a middleware function. So on the top once we create an app we call app dot use. Here we call express. json. This returns a middleware function that gets executed before passing that request to our request handler. So in an express application we can have one or more middleware functions. These middleware functions can be used for parsing request data, for enforcing security rules, for login and so on. So once we install the JSON middleware, we'll be able to access request. body otherwise this is going to be undefined. In other words, the JSON middleware gets executed before our request handler. It parses the JSON object in the request body and stores it in request. body. We're done with our first step. Next, I'm going to show you how to test this endpoint. All
Testing the API
right. Now to test our API endpoint, let's go to the extensions panel and search for Postman. This is a very useful extension for testing API endpoints. It's also available as a standalone application. In the past, I used to use the application, but recently I've been using the extension more. It's kind of more convenient. So, let's go ahead and install this. Once you do this, go to the command pallet. You can find it under the view menu. The shortcut is shift command and P on Mac and probably shift control P on Windows. Here, search for show Postman. You get this panel. The first time you have to create an account and sign in. I know it's a pain in the neck, but trust me, it's completely worth it. It only takes a minute. With this, we can save your HTTP requests in your account and share them across your different machines, so you don't have to recreate them every time. But you can also share your requests with other members in your team. It's very convenient. We're going to create a new HTTP request. We're going to send a post request to HTTP localhost port 3000/ API/ chat. Next, we go in the body tab, select raw for the type of data we want to send. And from this drop-own list, we select JSON. Now, here we add a JSON object to send to the server. So, we add a JSON object. We give it a property called prompt and set it to let's say what is the capital of France. Let's send this request. All right, we got a response with a status of 200. Now, let me put this side by side so you can see clearly. We can toggle the view mode. So, here's our request and here's our response. We have a message saying the capital of France is Paris. Beautiful. So, our API is working. Let's move on to the next lesson.
Managing Conversation State
lesson. Right now, our chatbot doesn't have a memory. So, if you ask a follow-up question, it doesn't remember our previous questions. Let me show you. So here I'm going to change the prompt to what was my previous question. Let's see what it says. It says I can't access previous interactions or questions. So how can we solve this? Well, one very basic way to solve this is by declaring a global variable for keeping track of the last response ID. This is a temporary solution. We're going to do things step by step. So outside of our route handler, we declare a global variable like last response ID. Now in terms of the type, this can be either a string if we have a valid response ID or null. The first time we're going to initialize this to null. Now every time we get a response from OpenAI, we update last response ID, we set it to response ID. Now when calling the create method in this options object, we can pass previous response ID to establish a conversation history. So let's set this to last response ID and test our API again. Back to Postman. Let's start by asking what is the capital of France. It says the capital of France is Paris. Great. Now let's ask what was my previous question. It says your previous question was about the capital of France. Great. So we have built memory into this chatbot. But there is a problem. Back to our code. With this global variable, we can only keep track of the last response ID for one conversation. But in a real application, we can have multiple users and each user can have multiple conversations. So the right way to address this is by using a map or a dictionary. So instead of one global variable, we declare a map. Let's call it conversations. We set it to a new map that here we specify the type of keys and values. I'm going to go with string and string. I will explain what it means in a second. So in this dictionary or in this map, we're going to map conversation ids to last response ID in that conversation. For example, we might have a conversation one and the last response ID in that conversation can be 100. Similarly, we can have another conversation and in that conversation in that thread, the last response ID might be 200. So we're going to replace this single global variable with a map. Now in our route handler first we should get the conversation ID from the body of the request. So let's grab it from this object. Let's call it conversation ID. Now this is the same experience we have in chat GPT. For example, when we ask a new question, let's say what is the capital of France? Look what happens in the URL. This client application created a Gwid or a globally unique identifier to represent this conversation. So the client is sending the conversation ID to the server. Now back to our code. We have the conversation ID. Once we get a response, we should update the last response ID in that conversation. To do that we call conversations set as the key we provide conversation id and as the value we provide response do ID and also when setting the previous response ID we should get the last response ID of the current conversation. So we call conversations that get we pass conversation ID and get the last response ID. Let's test this. So back to Postman, I'm going to start with what is the capital of France. Now I forgot to pass the conversation ID. So let's add conversation ID here. Now we can use a grid or just a simple string like con one. Let's start again. Okay. Now in the same conversation I'm going to ask what was my previous question. Now it's not updating. This is a glitch with Postman extension. I don't know if it happens on your machine or not. But if you go to the raw tab, you can see the updated response. Sometimes it doesn't update on the pretty tab. So if that happens you can simply close this window and reopen it. Alternatively you can use the raw or preview tabs. So here it says your previous question was about the capital of France. Great. Now let's open a new conversation and in that conversation ask what was my previous question. Let's see what happens. Again the pretty tab is not updating. So let's go to the raw tab. It says I can't access previous questions or conversations. Great. Let's ask a different question here and say what is after one. After one, the next number is two. Now let's repeat. What was my last question? It says your last question was what is after one? Now if you ask the same question but go to a different conversation, it says your last question was what was my previous question? So it's properly keeping track of the conversation history. So using a map, we can keep track of the last response in each conversation. Now in this implementation, we are storing these values in memory. In a real application like chat GPT, we should store these values in the database. But that's more complicated. We're not going to do any database work in this project because we just want to focus on foundations. Later in the course, we have another project that involves some database work.
Input Validation
In the last lesson, we assumed that everything would go smoothly. But in a real world application, we can't rely on that. We need to make sure that the request body contains valid data. More specifically, we want to make sure that prompt is a string between 1 and 1,000 characters. And conversation ID is a valid Gwid or globally unique identifier. just like the GUID we have here on chat GPT. So how can we implement these validation rules? This is where we use Zot. Zot is a very popular data validation library used in React applications. So let's open a terminal window here in the server directory. Let's add Zot. All right, good. Now with Zot, we can define the shape of our objects like incoming request data and easily validate them. So let's go to index. ts. First on the top we import Z from zot. Now down here outside of our route handler, let's declare a constant called chat schema. We set it to Z. Object. And here we pass an object for defining the shape of our incoming request data. So in the request we want to have a property called prompt. This should be a string. So we set it to Z dot string. Now here we can chain various methods for defining validation rules. For example, we can call min to specify a minimum length. Let's say a minimum of one character. Now here optionally we can provide a custom error message like prompt is required. Now, we can chain additional methods for defining additional validation rules. For example, we can apply a max length of 1,00 characters. Now, why do we do this? Because we want to prevent a bad user from posting a large amount of text and potentially bringing down our system or at a minimum, we want to prevent them from wasting our tokens. So, we should always apply constraint on the min and max length of our strings. Again, we provide a custom error and say prompt is too long. Max 1,000 characters. Now, when adding multiple validation rules, I like to break my code down into multiple lines. That makes it easier to see things like this. That's better. So, that's the prompt property. Now, in our request, we also want to have a property called conversation ID. This should also be a string. But here we don't want to apply a min and max length. Instead, we want to make sure that this is a valid UYU ID. That's short for universally unique identifier. So, UYU ID or GWIT, they're the same thing. Now that we have a chat schema, we go to our route handler. The first thing we're going to do is validate our incoming request data. So, here we call chat schema safe parse and pass request. This returns an object. We store it in a constant called parse result. Next, we check if parse result is not successful, then we set the status of the response to 400, which means bad request. This is the standard error code we use when the client sends bad data to the server. Also, in the body of the response, we want to add a JSON object to provide error messages to the client. We can get that object from parse result dot error dot format. Okay. Now finally we return. So the rest of this method is not executed. Now let's test this. So let's go to the postman window. Let's see what happens if we pass an empty string for the prompt. All right. Here's what we get. We get an object with three properties. errors which contains common errors as well as prompt and conversation ID which contain specific error messages for these properties. So for prompt we have an error saying prompt is required and for conversation ID we have another error saying invalid Uyu ID. Now let's see what happens if we pass a few white spaces. So with this we have a string that is at least one character long. But a string with white spaces is not a valid prompt. So let's see what happens. The error for the prompt property is gone which is not good. So to prevent this we have to go back to our schema. And before applying min and max rules first we trim the string. With this we get rid of the white spaces at the beginning or end of our string. Now let's send this request one more time. All right the error for the prompt property is back. Great. So let's pass a valid prompt like what is the capital of France. Now for conversation ID we need to pass a valid Gwid. How do we do that? Well we can install an extension for generating grids. So here search for Gwid or UIU ID. There are a lot of different extensions. I use this one UU ID generator. Now back to our request editor. We put the cursor here. Bring up the command pallet and search for UYU ID. We have two commands. The first one is generate UU ID at cursor. Let's select this command. Now we have this UU ID. Copy to clipboard. Let's replace con one with this valid UIU ID. Now we send this request. Okay, it's gone through and we got a response from OpenAI.
Error Handling
All right. Now that we have added basic input validation, the next step is to handle unexpected errors more gracefully. In this lesson, we'll update our API to catch and respond to runtime errors and return a proper error message to the client. So look on this line where we try to get a response from OpenAI. This line might fail for various reasons. Maybe the network is down. Maybe OpenAI servers are down. Perhaps we run out of tokens. Many different things can go wrong. Right now, we are not handling errors. So, to demonstrate this, I want to add an exclamation mark here to represent an invalid model. Now, let's see what happens when we send a request to our API. All right, we get this HTML document. If you preview it, here's what we get. Error 400. The requested model does not exist. And down below, we have our full stack trace. So we can see on which line this error has occurred. This is not a good response to return from our API. So instead we're going to handle this error and return a proper error message to the client. To do that we're going to add a try catch block in our route handler. So try catch in the try block we add our happy path. So we add all the code for getting a response, updating the conversations map and returning the response. We get all this code and put it inside the try block. Okay. Now because this line for extracting the prompt and conversation ID is closely related to the rest of the code, I want to bring this down and put it inside the try block as well for clarity. Now in the catch block, we handle errors. In case something goes wrong, first we want to set the status to 500, which means internal server error. And then we want to return a JSON object with an error property saying failed to generate a response. Okay, now let's see what happens if we send another request to our API. All right, here's what we get. We get a proper error message that we can show on the client.
Refactoring the Chat API
Right now there is too much happening in our chat API. We have some code for managing conversation state. We have our schema definition. We have data validation, the call to open AI. There's so much happening in this file. There's no real separation of concerns. Everything is just mixed together. It's kind of like a chaotic closet. It's hard to find a particular t-shirt in this closet. So now we're going to refactor or reorganize our code. Refactoring means changing the structure of the code without changing its functionality. It's like reorganizing a chaotic closet. We're not going to add or remove clothes. We're just going to move everything where it belongs. So we're going to have different sections where each section has one and only one purpose. So over the next few lessons, we're going to refactor our code and introduce a few layers into our application. Each layer will be focused and have a single responsibility. At the very top, we'll have controllers. Controllers are responsible for receiving HTTP requests and returning HTTP responses. They act like a gateway into our application. They're kind of like a receptionist in a building. Below controllers, we're going to have services. And this is where we'll have the actual application logic. For example, in our chat API, the piece of code for calling OpenAI to generate a response belongs to this layer, belongs to a service. Below services, we'll have repositories and this is where we have data. So, anytime we need to get or store a piece of data, we worked with a repository. Where that data exists, we don't care. It could be in the memory or in a database. If you follow this architecture, our codebase is going to be much easier to maintain. If something breaks or needs to change, we know exactly where to look. It also improves readability because each layer or module has one clear purpose and overall it makes our application more scalable because we can reuse and test each piece independently and plug them into new features later without duplicating code. So over the next few lessons, we're going to refactor our code and extract these layers one by one.
Extracting Conversation Repository
So we talked about the layered architecture in the previous lesson. Now in this architecture the direction of dependency between layers is always from top to bottom. So controllers can talk to services and services can talk to repositories but not the other way around. So the most fundamental layer in our application is the repository layer. In this lesson we'll introduce a repository and then in the next lesson we'll introduce a service that will use our repository. So I told you that repositories are for data access. Anytime we need to get or store a piece of data, we should use a repository. Now back to our code. Look here we have this line to keep track of our conversations. And these two statements for getting and storing the last response in a conversation. All these pieces are about data access and should be encapsulated inside a repository. So back to our project here in the server directory we add a new folder called repositories and in this folder we add a new file called conversation repository. ts. Now back to index. ts. First we grab this piece of code for declaring the conversations map. We cut it and move it into our repository. Now in this implementation we are storing data in memory. This is what we call implementation detail. Now when defining our modules we don't want to expose implementation detail. So export this constant from this module. Instead we should export what we call the public interface of the module. Let me give you a metaphor. Think of a remote control. A remote control has a bunch of buttons on the outside that we use. But it also has a complex electronic board on the inside that we don't care about. That's the implementation detail and the buttons on the outside are the public interface. So when creating this module, we want to keep the implementation detail private and only export the public interface. In this application, we need two functions for getting and setting the last response ID. So we export a function called get last response ID. We give it a parameter conversation ID of type string. Now we go to our index. ts and grab this piece of code, cut it and move it into our module. And of course we return the result. With this in place we go to index. ts ts and here we call get last response and pass the conversation ID. Similarly, we export another function called set last response ID. We give it two parameters conversation ID which is a string and response ID which is also a string. Now we go back to index. ts ts and grab this statement, cut it and move it into this function. We just have to make a tiny change. All right, good. ts. Here we call set last response ID and give it two arguments conversation ID and response. ID. Now with this change, we have kept the implementation detail private and only exported these two functions. That means this index. ts module doesn't know anything about where the data is stored. Right now it's in memory. If in the future we decide to modify our repository and store the data in a database, this module is not going to be affected because it's only dependent on these two functions for getting and setting the last response ID. There's just a tiny problem here with our current implementation. These two functions look kind of like utility functions. The responsibility or the layer they belong to is not quite clear. So here we're going to take a different approach. We're going to go to our repository. Instead of exporting these to standalone functions, we're going to export a constant called conversation repository. This is an object with two methods. I'm going to grab this function definition, copy it, paste it here. Now, let's also do the same for this other function. So, now we're exporting an object called conversation repository with these two methods. Now, we can remove these functions. Save. Back to index. ts. Now on the top we have these two errors because these functions no longer are exported. So instead we import conversation repository from this module. Now down here we prefix these function calls with conversation repository like this. Now it's quite clear in the code that we're asking the conversation repository to give us the last response ID. So that was our first step. In the next lesson, we're going to introduce the chat service.
Extracting Chat Service
All right. Now, we're going to introduce a service and this is where we'll have the actual application logic. So, I told you that controllers act as gateways. A controller receives an HTTP request. It validates it. If it's valid, it calls a service to do the job. So a service shouldn't know anything about HTTP requests and responses. Now here in our chat API up to this point we are working with the request object and down here response. So all the code in between belongs to a service. So let's go to our project and here in the server directory add a new directory called services. In this directory we add a new file called chat. service. ts. TS. Now here we export an object called chat service and give it a single method that is send message. Here we need two parameters prompt which is a string and conversation ID which is also a string. Now back to our index module. Let's grab this piece of code for calling OpenAI to generate a response and also this line for updating the last response ID in our conversation. So let's cut these lines and move them to our chat service. Now here we need to import the conversation repository. So real quick. Okay. Now we have an error on this line because we are using the await keyword. So let's mark this as async. We should also bring the client object from our index module. So back to the index module. Now let me show you another cool shortcut. Press command and P on Mac or control and P on Windows. Earlier we used this shortcut to jump to a file in our project. Now here if we type an at sign we can jump to a symbol in this file. A symbol can be a variable, a constant, a function and so on. So here I want to find the client object. There you go. Now we can grab these few lines, cut and move them into this module. Now let's import open AI. Good. Now in this implementation, this client object again is implementation detail. So we don't want to export it outside of this module. So this is the only module in our application where we know what LLM we're going to use. If tomorrow we decide to move away from open AAI and use a different LLM, this is the only module we should modify. So the consumer of this module which is going to be our index module shouldn't know what LLM we are using under the hood. That is the implementation detail and what we are exporting here is the public interface. Okay. So we don't have any errors in this module. Now let's go back to our index module. on the top we can remove this line for importing conversation repository as well as open AAI. So our index module is getting leaner with each refactoring we have been doing. Okay. Now we have an error down here. So first we extract the prompt and conversation ID from the request body. Right after we call chat service dot send message. We give it two arguments prompt and conversation ID. We await the call and get a response. Now here we have an error because I forgot to return the response object from our service. So after we get the response, we update the last response ID in our conversation repository. And finally we should return the response. Okay, no more errors. Everything is working. But there's a hidden problem here. Look here we are getting this response object but this object is specific to the open AI platform. So here we are using the output text property to return a message to the client. But what if tomorrow we use a different LLM like Gemini and the response object we get from Gemini doesn't have a property called output text. So back to our chat service. This chat service object that we are exposing is what we call a leaky abstraction. What does it mean? Well, this is an abstraction over open AAI because it hides the complexity. It hides the details. The consumers of this module like our index module don't know what LLM we are using under the hood. So the chat service is an abstraction over open AAI but it's a leaky abstraction because some of the details are being exposed to the outside to the consumers. In this case we are returning this response object which is specific to the OpenAI platform. That's why we say this service is a leaky abstraction. To solve this problem we have to introduce a new type that would be platform agnostic. This will represent a response from an LLM. So up here, let's define an interface. We can also use a type. It doesn't really make a difference. We call this chat response and give it two properties. At a minimum, we need an ID, which is a string, and a message, which is also a string. Next, we annotate this method with its return type. It should return a promise of chat response. Okay, now we have an error because down here we're returning this response object which is not an instance of the type that we just defined. So we have to return a custom object. Here we add two properties ID which we set to response ID and message response. output text. So if tomorrow we decide to use a different LLM and that LLM doesn't have an output text property, this is the only place in our codebase we have to modify. In other words, this module, this chat service encapsulates all the details for working with an LLM and exposes a simple interface that is our chat service with the send message method. Okay, now back to our index module. Here we have to change output text to message. Now, one last thing before we finish this lesson. I just noticed that in our chat service, we are using the wrong model. So, let's remove this exclamation mark. Great. We're done with this lesson. In the next lesson, we'll introduce a controller.
Extracting Chat Controller
Earlier I told you that a controller is a gateway to our application. It receives an HTTP request and returns an HTTP response. As part of this, first it validates the request data. If it's invalid, it returns an error. Otherwise, it calls one or more services to perform some functionality. And finally, it returns a response to the client. So now, we're going to grab all the code inside this function or this route handler and move it to a controller. So back to our project here in our server application, let's add a new directory called controllers. Inside this directory, we add a new file called chat. controller. ts. In this module, we export an object called chat controller and give it a method called send message with two parameters request and response. Now while this code works it's better to explicitly import these types from express. So on the top import type request and response from express. Now back to our index module. Earlier we talked about the shortcut for extending selection. You can find it up here. On Mac it's control shift command and right. So the cursor is here. I'm going to hold shift, control, and command with my left hand and keep pressing the right arrow to extend the selection. Now we have the semicolon, now the entire line. Keep going. We have this entire try catch block. Let's keep going. And now we have the entire body of this function selected. Cut. Let's paste it into our chat controller and save the changes. Now let's see what's happening. So we're using the chat service. Let's import it on the top. We're using await. So let's make this method async. Also, we need the chat schema. So back to our index module. Once again, command P on Mac or controlMP on Windows. We type an at sign and find chat schema. Again we extend the selection, grab this object, cut it and move it to our controller module. Here we need to import Z from Zod. Good. No more errors here. Now in this implementation, this chat schema is implementation detail and this chat controller is the public interface. So the consumer of this module which is the index module shouldn't know what library we are using for validating data. That is implementation detail. In other words, it's none of index modules business. The index module just needs a method for sending a message to the application. How the request is validated doesn't matter. Right now we are using Zot. Tomorrow we might use a different library. If we decide to replace Zot with something else, this is the only module we want to modify. We don't want to modify both this module as well as the index module. Okay, so no more errors here. Now back to the index module. We're going to replace this lambda function with chat controller dot send message. Now jump to the top. Let's remove this unused import statements. So we can press command and period on Mac or control and period on Windows and delete all unused imports. So again, our index module is getting cleaner with each refactoring. But we're not done yet. There's one more refactoring which we'll do next.
Extracting Routes
Right now, all of our route definitions are in index. ts. That works fine for small projects, but as our application grows, keeping everything one file can get messy. So in this lesson, we'll move our route definitions into a separate file. It's a small change, but it helps keep our code clean, modular, and easier to maintain as we add more endpoints. So, back to our project here in the server application, we add a new file called route. ts. In this file, first we import express from express. Now, back to index. ts. Let's grab all our definitions, cut, and paste them here. Now, here we're using the request and response types. So, let's import them from express import type request and response. Okay, good. Now, in this module, we are not going to work with the app. Instead, we're a router. So, here we create a router. We set this object to express router. On this router, we register our endpoints. Now, let me show you another cool shortcut. Let's say we want to rename all instances of app to router. With app selected under the selection menu, look at the shortcut for select all occurrences. On Mac, it's shift command and L. On Windows, it's probably shift control and L. So, I'm going to press shift command and L. Now we have multicursor editing. All instances of app are selected. So we press backspace and replace them all with router. Now to jump out of multicursor editing, we press the escape button twice. Okay, good. Now down the bottom, we should import chat controller. Okay, now technically in a real application, we shouldn't have our route handlers. We should only have a reference to a function inside a controller, but we added these earlier for demonstration as part of setting up our full stack project. These are oneliners. I'm not worried about them at this point, so we don't need to change them. Now, finally, at the end, we export the router as the default object from this module. I'm using default because this is the only object we should export from this module. Now, back to our index module on the top. We don't need this line anymore. Let's remove it and also chat controller. Instead, we import router from the current folder / routes. So, we configure our environment variables. Next, we create an application. We use the JSON middleware right after we add our router. Okay, so these three lines are closely related. They're all about setting up our app. They're a little bit different from initializing the port. So I like to add a line break between the two. So we create the app, initialize the port and finally start the app. Now our index module is much cleaner. We only have the necessary code for starting the application. All the details are somewhere else. Now let's review what we have done so far. So we created the routes module where we have our routes or endpoints. Now right now we only have a single file for this purpose. But as our application grows, we might have various route files for different functional areas in our application. For example, we can have a route file for registering all the routes related to products and categories. We can have another route file for managing orders. admin endpoints and so on. Okay. So in this route file, we are using the chat controller as our route handler. Let's take a quick look at this method. This controller is the gateway to our application. It receives an HTTP request, validates it. If it's valid, it asks the service to do the job and finally it returns a response. Now, in our service, we have the application logic. So, for a chat API, we have the code for calling an LLM to generate a response and updating the last response ID in our conversation repository. In our repository, we only have data access code. There is no HTTP request here. There is no LLM call. There is no middleware setup. So over the past few refactorings, we broke down index. ts into a set of small and focused modules. Each having a single responsibility. The repository has data access code. The service has application logic. The controller acts as the gateway and the routes has all the route definitions. So we're done with implementing the back end. Over the next few lessons, we'll start building the front end.
Building the Frontend
Now that the back end is ready, it's time to move on to the front end. Just like we did with the back end, we're going to build a fully functioning chatbot step by step. And once everything works the way it should, we'll take time to refactor and organize our code to keep it clean and modular. Let's jump in. And that's it for this tutorial. What you just watched is the first two hours of my full 7-hour course on building AI powered apps. If you enjoy this and want to keep going, the full course covers everything in much more depth. You'll find the link in the description. I would love to have you join me in the full course, and I can't wait to see what you will build.