Transcription
What if you could ask any book a question out loud and hear it answer you back in a real human voice? That's exactly what you're building today. Hi, I'm Adrian and by the end of this video, you'll have a full stack AI app where you upload any PDF, pick a voice, and have an actual conversation with it.
That 500-page textbook, just ask it what you need to know. Your entire library lives in one place. Every book you've uploaded searchable instantly. Adding a book takes seconds. Drop in a PDF, pick a cover, enter a title and the author name. Finally, choose a voice persona and that's it. The AI processes every single page and you can watch it happen in real time.
And I just uploaded clean code. You know that book that your senior dev keeps telling you to read. Hey, just be honest with me. I've got a function that's 200 lines long. How bad is that really?
A 200-line function is generally considered too long and can be difficult to manage or understand. Functions should ideally be small, often just a few lines to ensure clarity and maintainability.
Well, I guess that's what I'll do. See that? It didn't just make something up. It went into the actual book, found the relevant chapter, and talked me through it like a mentor sitting right next to me.
Oh, and I'll also teach you how to add built-in subscription tiers, so you can turn this into a real SaaS application that you can actually charge money for. Free users get five sessions per month with one book, but standard and pro unlock so much more, and the whole app works beautifully on mobile.
This is exactly the kind of project that makes you stand out in these new times of AI development. It has AI, real-time voice, and a product that's actually ready to sell. You're building this on Next.js, MongoDB, Tailwind CSS, and TypeScript. The exact stack showing up on serious job listings right now.
Vapi orchestrates the real-time voice calls and handles the complex conversation logic so your app feels smooth and natural. Clerk handles auth and subscriptions with up to 50,000 monthly active users for free. This is the missing piece between a portfolio project and an industry-level workflow.
And to top it all off, throughout the entire build, alongside me, Code Rabbit will act as your AI senior reviewer, catching architectural flaws and keeping your code at production quality before anything gets merged. Look, if you're serious about leveling up from watching tutorials to actually becoming career-ready, you'll love JS Mastery Pro. It's where I go beyond the "build this app" stuff and teach the engineering mindset behind the code.
Inside Pro, you get full premium courses like the ultimate Next.js, testing, animations, JavaScript, SQL, Three.js, and more. Quizzes after every lesson, so you actually lock in what you learn. Interview practice with our AI interviewer, so you can train for real technical interviews the same way you train for a sport. And we're also launching the ultimate back-end course and our AI engineering courses next. And our pro members get early access. You also get access to our private Discord where you can ask questions to real human beings and get help fast.
If you want to check it out, I might give you a special discount just because you're coming from this video. Give it a shot. The link is in the description. In the description, you'll also be able to find a free video kit which will give you everything you need to develop this project together with me and learn a ton. So, let's dive right in.
Let's get started right from the beginning by creating a new empty folder on our desktop. And you can call it something like Bookify. Then open up your text editor or IDE of choice. In this case, I'll be using WebStorm. And within it, open up the empty folder you just created. Throughout this course, you'll see me use WebStorm. I switched over to it as soon as they became free for non-commercial use because for your personal projects, you're now getting the power of a real professional IDE that's typically quite expensive. So, I'll leave the link down in the description so you can download it.
And another reason why I like using it so much is because it comes paired with Juny, the coding agent created by JetBrains, which allows you to be that much more productive. So, throughout this course, you'll also see me use Juny to develop specific functionalities that are going to allow us to focus on building the architecture and the business logic of the project, which is what actually matters, and not working on those repetitive tasks. So you can go ahead and download and set both of these up. It looks like Juny just got a big update. So we can use it from a single AI chat. I'll go ahead and try this new AI experience. And this is how it looks like. I'll teach you all about how to use it to stay relevant as a developer in this super fast industry throughout this course. So that's another huge thing you'll learn.
With that in mind, let's go ahead and set up our project. We'll start by setting up a new Next.js application by heading over to nextjs.org and copying the installation command. Then simply paste it within the terminal and add `./` at the end to create it in the current directory you're in. It'll ask us whether we want to install the installer. So just say yes, please continue. And we're going to use the recommended defaults: TypeScript, ESLint, Tailwind CSS, and the App Router. Everything we need.
Now, while our app is getting set up, we can also create an account on Clerk, which we'll use for both authentication and subscription management. And I want to quickly head over to their blog to show you that they recently completely revamped the pricing where they increased the number of monthly users that you can now have within your application for free from 10,000 to 50,000. And this made me really want to use it for this project. So now if you check out the pricing, you'll see that it is completely free for up to 50,000 monthly active users. I'll leave this special link down in the description so you can head over into it and create your free account. Once you do, also create your first application and call it something like JSM Bookified, similarly to what I've done for this project.
And now that our app got initialized, you can simply run `npm run dev` to run it on localhost:3000. And back in the browser, you can open it up to see this starter, which soon enough we'll completely remove and start from scratch.
And to be able to implement all sorts of different styles very quickly and consistently, we'll of course use shadcn. So just go ahead and go to their docs, installation, Next.js, and copy the first installation command which will add to a new terminal. This one I'll call `app` because it's running, and this one I'll call `terminal` so we can install additional things. It's `npx shadcn-ui@latest init` and just press enter. It'll ask you whether you want to install it. So just say yes and you'll be able to choose from a couple of options. Go with the neutral color. It'll install all the dependencies.
And then we can go ahead and add our first shadcn component, which is going to be the button. By typing `npx shadcn-ui@latest add button`. I love shadcn because it doesn't just install all the components immediately, but you get to choose which components get added. So then their entire code is right here for you as if you wrote it and you can fully change it.
So now let's test it out to see whether it got installed properly by first removing all the contents within the current page. Typing `rafce` to quickly spin up a new page. And if this wasn't working for you, you might not have the necessary plugin which is called React Modern Snippets. So simply install it and then you'll be able to quickly spin up new pages. Right here we can enter an H1 and say something like "Bookify" and give it a class name set to `text-xl` and `underline` to see whether Tailwind and shadcn apply the necessary styles.
We can also quickly head over into the layout to modify the fonts. So instead of these two fonts that we have right now, I'll add two more. The first one will be IBM Plex Serif. And this is going to be equal to `IBM_Plex_Serif`, which we can import automatically coming from `next/font/google`. We can choose a variable to activate this font. That's going to be `font-serif`. And you can just notice that WebStorm AI autofilled this for me. Let's also proceed with a subsets that we want to get. In this case, that's going to be `latin`. Then we can choose specific weights that we want to add to it. I'll add `400`. Let's also do `500`, `600`, and `700`. There we go. So, we have some variations. And finally, I'll give it a `display` property of `swap`.
Now we'll do a similar thing for another font which is going to be Mona Sans and this is going to also be coming from `google/mona-sans`. We'll give it the necessary variable right here of `font-mona-sans`, a subset of `latin`, and in this case we don't need any additional weights. We'll just add a `display` of `swap`.
Then we can also add some metadata about our application such as the title, which is going to be "Bookify". And we can add a quick description saying "Transform your books into interactive AI conversations. Upload PDFs and chat with your books using voice." This is exactly what we want to achieve and trust me by the end of this video you'll have just that. Finally, we have to use those two fonts right here by saying `IBM_Plex_Serif` as well as `MonaSansVariable`. And we can also add a `relative` and `font-sans` class names.
Now, if you head back over to your application, you should be able to see a single piece of text that says "Bookify". But what do you say that we style it a bit further?
Now, let's immediately make it look better by heading over into our `globals.css` and overriding everything you have here with the new `globals.css` that I'll provide for you in the video kit link down in the description. Just head over there, find the `globals.css` and override it. Here you'll see that I added some new colors and theming to our application as well as some variables that we can use throughout the application that have this nice warm literary background. Again, most of this was generated by AI for me. Nowadays, AI is amazing at creating these globals for theming. And you'll also see a couple of class names such as `label` or the `author-wrapper` or the `page-title` even, which we are reusing across our application many times. So it's easier to just create a utility class name which then replaces all of these additional class names. I'll show you how that works. But as soon as you replace it, the look and feel of your application should change. Keep in mind this is the final app design that we're going for. It might not look something like you use every day, but you got to understand your app from your users's perspective. Bookworms and people who like to read a lot prefer these kinds of UIs, which is exactly why I decided to go for this one for this type of a project.
And with that, our setup is done. In the next lesson, we can dive right into creating our first component, which is of course going to be the navbar.
To start creating the navbar, go ahead and create a new file within the `components` folder and call it `navbar.tsx`. I can add it to git automatically and then run `rafce`. And let's start creating it. First, let's make sure it's actually being utilized within our primary layout right here within the `body`, but above all the other pages. We will render a self-closing `Navbar` component, which you also must not forget to import from `components/navbar` throughout this course. You'll see that whenever I type something and then I autofill it, it'll auto-import it for me. But if that doesn't happen for you, just don't forget to manually import it right here at the top. I'll also be separating all of my imports by one empty line. The ones at the top will be external imports and then right below it are going to be our internal imports, which are from our own files. Just a good practice to follow.
Then we can head into the navbar and start implementing it. But of course, at this time, it might make sense to show our browser side by side with our editor. So let's do it like this and continue. I'll turn this into an HTML5 `header` tag and give it a class name set to `w-full` for full width, `fixed`, a `z-index` of `50` so it shows above other content, and I'll give it a background a variable of `bg-primary` that's going to look something like this. Of course, now the Bookify text will go over it, but since we're developing just the navbar, I can remove this H1 from the homepage for the moment. Then right within, create a new `div` that'll have a class name set to `wrapper navbar-height`. So we give it some height and `py-4` for that vertical padding, as well as `flex justify-between` so we can show the elements on the left and the right, and `items-center` to center them vertically.
Then within that `div`, we can add our first link, which is going to be coming from `next/link` and it'll point to the `/` which is the homepage. We can give it a class name of `flex`, a `gap-2` between the elements, as well as `items-center`. And within this link, we can render an `Image` coming from `next/image` which is going to point to our `assets/logo.png`. The `alt` tag will say "Bookify" because that's the logo that it is pointing to, with a width of let's do about `42` and a height of `26`. And of course, that'll show as a broken image right now. That's because we don't yet have access to this image. So in the video kit link down in the description, you'll also be able to find a final zipped `public` folder. So, go ahead and copy it and then remove the current `public` folder from here. And then simply drag and drop this new one after you unzip it. Within it, you'll have access to all of the assets we'll use. And one of these is this logo, which you'll be able to see on the page very soon. There's also the SVG logo and this hero illustration that we'll use later on. So, if you reload, you should be able to see it.
And when we pair this little image with a `span` element that'll say "Bookify" with a class name of `logo-text`, it'll all start making more sense. But this text will only be visible on larger screen sizes. So make sure to give it some space. After that, we can start creating all of the other elements such as the `nav-item` that'll have a class name set to `w-fit flex`, a `gap-7.5` between the elements, as well as `items-center`. And here we can map over some of the items that we'll have. That's going to be just an array of two items. The first one will be an object with a `label` saying "Library" and an `href` of `/`, which is the homepage, and the second one will be "Add New" for adding a new book, which is going to redirect us to `/books/new`.
And now we can simply map over these items within the nav by opening up a new dynamic block of code and saying `navItems.map` where we extract the `label` and the `href` and instead of returning it automatically, we can open up a new block of code so that within it we can define how the active element will look like. To be able to get access to the active element, we have to use the `usePathname` hook at the top. So I'll say `pathname` is equal to `usePathname` coming from `next/navigation`. So it's going to be active if `pathname` is equal to the `href` that we're getting right here when mapping over the elements, or if `href` is not equal to the homepage and `pathname` starts with that `href`. Of course, as soon as you use the `usePathname` or any other hook that starts with `use`, you have to turn this into a client-rendered component to be able to use all of those browser switching functionalities.
And then within here, we can finally return a new `Link` component with an `href` of `href` and a `key` of `label`. And within the link, we can simply render the `label`. So now you can see the "Library" and "Add New" pages right here as well. The "Library" points to homepage, so it exists, but the "Add New" page doesn't exist yet.
Now let's also make sure that the active state is turned on. We can do that by applying a class name to this link which is going to be dynamic. So we need to use the `cn` property, class names, and always provide a `nav-link-base`, but then only if it's active, also give it `nav-link-active` class. Else, we'll give it a `text-black` and `hover:opacity-70`. So it changes on hover. So now you can see how "Library" is active. Now I can hover over "Add New". Then this one is active and this one is not.
And with that, we have our very simple navbar. Even though it's super simple, it took some time to actually write all of this down. But already in the next lesson, we're diving deep into authentication where I'll show you that we'll be able to authenticate our users and manage their logged-in state in less time than it took us to set up this super minimal navbar.
Let me show you just how simple setting up the auth actually is. Click the link down in the description, create your account, and head over to your dashboard. Then create a new application and enable whichever options you prefer. There's literally tons of different options, but I'm going to go with email and Google and call it something like JSM Bookified, or you can choose your own name. I'll also expand the screen a bit more so you can see that in real time you can see how your authentication box is going to look like with all of these different options. Then just create the application and we'll be able to sign up our first user.
Now, what I'm about to show you is the future of web development. There are steps on how we can set Clerk up. And don't get me wrong, those steps are super simple. But check this out. Right at the top of them, there's a call out that allows you to copy the quick start guide as a prompt for LLMs to implement Clerk in your Next.js application. So, copy that prompt, head back into your editor, and whichever LLM you're using, open it up. I'll open up Juny by pressing `Command + Shift + B` and then searching for Juny and pressing enter. And as you can see within WebStorm's AI chat, you can also use different agents, but I'll proceed with Juny to show you just how well it works. So the only thing you have to do is paste the prompt that you copied over from Clerk and press enter. Juny will then analyze the steps, read, go through and read your entire codebase and it'll come up with a plan. Then it'll ask you for your permissions to install it. And you can also click these three dots and say "Add similar commands to the allow list" so it can run them without asking you. And it'll proceed with modifying the files one by one, ensuring that your application works well.
What I'm super interested in is that it's also modifying the navbar that we just worked within. So it's now going to be updated to include the user sign-in and sign-out buttons alongside the existing navigation links. And while I was speaking in real time, it already implemented it. We can go through and read the changes, but I would rather like to see the code. So, if you head over into the navbar, check this out. It imported a couple of components coming from Clerk. And it added them right here: `SignedOut`, `SignIn`. If we're signed out, show the sign-in button, but if we're signed in, show the `UserButton` instead. The only thing that's left for us to do is to add our own env keys. We need a `Clerk publishable key` and a `Clerk secret`. And you can get that secret right here in the second step by copying it and pasting it right here.
Once that is done, you'll be able to see a new button right here next to our routes. So, if you click on it, you'll now be able to sign in. I'll of course continue with Google. You just need to select your account and then click continue. And in a matter of seconds, once you verify you're human though, we are redirected back to our application with now a new signed-in account. Just how amazing is that? Typically, this would take so much time. And let's be honest, authentication isn't a crucial part of your application. Something else is. So, this allows us to move faster and develop the features that our users actually care about.
Let's improve this just a tiny bit by heading over to this part where it says `SignedOut`. And actually, let's put both of these containers into a new `div` that's going to have a class name set to `flex gap-7.5 items-center`. And then under `SignedIn` right here next to the button, we can first wrap it in an additional `div` that has a class name set to `nav-user-link` that renders this `UserButton`. But also we want to render their name because I want to show you how simple it is to extract the data from your signed-in user. Right here at the top where we get the path of the application, you can say `const { user } = useUser()`. And this `useUser` is coming from `clerk-nextjs`. So you can just import it from here. Then right below or right after the `UserButton`, you can check if `user.firstName` exists. Oh, and I think I needed to extract the user from here, of course. Then if it exists, we can simply display a `Link` that's going to lead the user to their subscriptions, which is going to be another feature that we're going to implement through Clerk soon. And it'll have a class name of `nav-user-name`. And within it, we simply want to display the user's first name. So now, if you do this, you'll be able to see your logo, but then if you expand your screen a bit more, the name will also show up. And when you click on it, it'll redirect you to the subscriptions page, which we're going to implement later on.
And now if you head back over to your Clerk dashboard, you'll be able to see "Congratulations, your application has users." You can now further configure it by setting up multi-factor authentication or even you can go passwordless. Clerk even has their own MCP server, which means that it can speak to your AI agents to continue developing the apps further. And here you can track your users. Later on, we'll focus on this billing feature as well, which is currently in beta but is already super stable, and it has to be because it's handling transactions. So, I'll show you just how simple it is to not only set up auth, but also start charging for the features that you implement within your applications. For now, that's how simple it was to set up user management through Clerk, which also handles all those edge cases that you really don't want to think about when dealing with signups and managing users.
And now, let's build out the homepage so we can turn this empty page into something looking a bit more like this. To do that, we can head over into our `app` and then `page.tsx` because this is where everything within the homepage resides. And I actually think that these pieces of UI are the perfect thing for AI to build out. So I'll take a screenshot of the final application, which you can do too. It'll be deployed and the final link will be in the video kit link down in the description. Then once you take a screenshot, open up Juny and simply drag and drop it in. Alongside the screenshot, we can also provide it some instructions. Something like: "Create a hero section for a book library app with a warm beige card that contains three parts side by side. On the left side, we have the 'Your Library' heading, a short description, and 'Add New Book' button. Then in the center, we have an illustration of vintage books and a globe. And then finally on the right, we have a small white card showing three numbered steps. Replicate the design perfectly." I was basically explaining how it's supposed to look like. I'll say "Provided in the screenshot." And let's see how well it does.
It's recognizing the layout. It'll use the `globals.css` that it already has. It'll find the `public` assets directory and find the illustration that it needs to use. It'll read the navbar, most likely see that it doesn't have to do anything there, and then it'll dive into the homepage where it'll start developing it. Or if it's even smarter, it'll develop its own hero section component that it'll use within the homepage and then it'll add the left part, the center part, and the right part right into it. While it's developing, we can take a look at the changes that it makes live in the browser. And just take a look at this. I can see the icon popping up from my desktop, which means that it's finished. And there we go. It's implemented. We have the left part right here, the center part here, center part for mobile that is hidden on desktop, and then finally the full right part with the steps. And notice how it actually uses the styles that were already provided within the `globals`. This is absolutely perfect. And now if we compare the first and the second one, you can see that this one is a bit more zoomed in. But other than that, it might even look a bit more uniform than the final application. In my opinion, this is the perfect way to leverage AI tools because it wrote about 70 lines of code here that would take us a lot of time to write. But would we have really learned anything or would we have just been writing classes and divs and H1s that we already know how to write? No. Now we can focus on something more exciting.
So right below this library hero card, we can now map over all of our books by creating a new `div` that has a class name set to `library-books-grid`. And within this grid, we want to map over all of our books. Now, you could go ahead and declare some dummy books data right now while we're building the UI right here at the top by saying `const sampleBooks` and then create a huge array right here cluttering the hero section. But it doesn't really make sense to do it here. Nor does it make sense to have the `library-hero-grid` here because this is the section. Rather, we're going to go back into the homepage, render this `library-hero-grid` here, but we're going to store the sample books somewhere else. I'll create a new file right here within the `lib` file and call it `constants.ts`. Within `constants`, you declare some files that you often use within your application. And I'll provide you with the full final `constants.ts` file right in the video kit link down below so you can copy it. This might include some Clerk auth appearance overrides if we do decide to implement them later on. And some configurations for our speaking agents, but more or less the thing that we care about right now aren't going to be these `navItems`. We already created those within the navbar, but rather the `sampleBooks`, which have their own IDs, titles, authors, slugs, and then even cover URLs.
So now we can map over these `sampleBooks` by saying `sampleBooks.map`. We get each individual book and for each one of these books, we can automatically return a new component called `BookCard`. Of course, this is a new component we have to create within the `components` folder. So let's just create it `bookcard.tsx` and run `rafce` to quickly spin it up and then we can import it right here to be able to use it. To this `BookCard`, we can provide all of this information from the book such as a `key` which is going to be equal to `book._id` (because later on we're going to use MongoDB), we can provide a `title` `book.title`, also the `author` which is going to be set to `book.author`, we also need to provide the `coverURL` set to `book.coverURL`, and finally a `slug` to know which page to navigate to, which is going to be `book.slug`.
And now we got to get into the `BookCard` and implement it because right now we can just see "BookCard" 10 times and those really aren't our real cards. So let's start implementing it by making each book a link. Why a link? Because clicking on it will lead you to the details page of that specific book, or not even the details page, but the conversation page with that book so you can speak with it. So I'll give it an `href` pointing to a dynamic link of `/books/` and then the `slug` of that book. The `slug` of course is coming through props. So we can destructure the `title`, the `author`, the `coverURL`, and the `slug`. And these are going to be of a type. Well, of course, now we would have to write all of these types manually. `title` is string, `author` is string, `coverURL` is string, and so on. But instead, we can define a new interface right here at the top. Interface called `BookCardProps`, which will contain all of these different types. And you just saw that I was basically just tabbing it through with WebStorm, which autocompleted everything for me. Um, but we don't even have to keep those interfaces within these components. Rather, we can keep them within a new file called `types.d.ts`. And that way, our codebase will recognize them automatically. Let me show you what I mean. If I create that new file within the root of our application called `types.d.ts` and put this interface right within it and then export it from here. Then when we head back over here, you'll be able to automatically import this type from `types` and it'll now know that the `slug` is a string. So later on throughout this application, we'll have many other interfaces and types. And just so you don't have to type them all manually, I'll provide you with the final `types.d.ts` in the video kit link down in the description. That way, your application will be type-safe. And if you mistype something, it'll be easier to fix it later on while you're following along with the video. For example, if you mistake the book ID with a string instead of an object ID, or if you misspell something, TypeScript will save you and tell you that something is wrong.
Okay, great. So, with that in mind, we have created this `Link` component right here. And we can now put this side by side so we can actually see these books we're creating. Within this link, I'll render an `article` that's going to have a class name of `book-card`. And then within the `book-card`, we'll render a `figure`. Typically, an `article` is like a standalone piece of UI like a card. And then a `figure` tells you what is within it. So it'll have a class name of `book-card-figure`. And then within it, we'll render a `div` with a class name of `book-card-cover-wrapper`, which will render an `Image` component that has a `src` of `coverURL`, which we're passing through the props, and a class name of `title` that we're passing in. It'll also have a width of about `133` and a height of `200` to have that typical book cover size. And finally, we can give it a class name of `book-card-cover`. If you save this, you'll see that Next.js will try to load these URLs from `covers.openlibrary.org`, but we haven't yet added that hostname under `next.config.js`. So head into your `next.config.ts`. You can add the `images` prop. And then under `images`, you're going to add `remotePatterns`. And then you can add an array where we're going to add our first object with a `protocol` of `http` and then `hostname` of `covers.openlibrary.org`. Let's make sure that it looks nice like this. Perfect. And then you'll be able to see all the different covers of all the different images.
So now let's render other information about the book as well, such as the `figcaption`. In `figcaption`, we can give it a class name of `book-card-meta` as in the metadata or the meta information about the book, where we can render an `h3` that'll have a class name of `book-card-title` and it'll simply render `title`. And similarly, we can render `book-card-author` and then the author information below it as well. And that's going to look just like this.
Now, if you expand this, the UI still isn't great. It looks like each image is taking the full width of the screen, which means that the issue is within our `library-books-grid`. That's because it's not a hero grid. It's a books grid. And this class name will apply a grid of `grid-cols-2` or on medium devices `grid-cols-3` or on large `grid-cols-4` or on extra-large `grid-cols-5` per row, which is going to make those books appear one next to another. Now, they should also not be going outside of this container, outside of the hero section. Which means that if we go back, we need to wrap both the hero section as well as this `div` in this `main` that has a class name of `wrapper` and `container` because it'll then contain all of these books together. And we can give this hero section, if you go into it, a class name of `mb-10` as well as on medium devices `mb-16`, which will divide it a bit from the rest of the books. And I mean, just take a look at this. We already have what seems to be a fully functional UI that actually leads you to the conversation page of that specific book.
Now what should we do next? Should we implement the UI of the "Add New Book" page or the "Book Conversation" page, or should we set up the database so we can actually upload real books? I think it makes sense to develop the "Add New" page UI because then we'll know which fields we need to structure within our database so that it makes sense with the whole final application. So in the next lesson, let's implement the "Add New Book" page, which is going to look something like this.
To get started developing this great "Add a New Book" form so we can finally well, start adding books. We can head back to the current version of our application where there's just the 404. Then I'll pull this to the side and create a new route right within the `app` folder. We want to create a new route group by creating a folder which is wrapped in parentheses and I'll call it `(root)`. This means that this folder is not added to the URL bar, but can have its own layout. And then I'll move the current homepage within the `(root)` route group. Within it, I'll create a new folder called `books`. And within `books`, another folder called `new` and within `new`, a new `page.tsx`, which is where we're going to put our new book form. And as soon as you do this, you'll be able to see that. Now, if you head back over from the homepage where you can "Add a New Book", you go straight to this new page.
So, let's get it developed by turning this into a new `main` component with a class name of `wrapper` and `container`, which is going to put the page right below it. Then, within it, I'll render another `div` that's going to act as the container, and it'll contain the content within a specific width, so it doesn't extend to the full width of the screen. So that's going to be `mx-auto max-w-180` as well as `space-y-10`. Then within it, I'll create another section that has a class name set to `flex flex-col`. So the elements appear in a column, one below another, and a `gap-5`. And we can add our heading. It's going to say "Add a New Book". And we can also give it a class name set to `page-title-xl`, which is now going to make it big and it'll also apply the font that we're using. Perfect. Below this, we can also render a new paragraph that's going to have a class name of `subtitle`. And we can give it some kind of a text, maybe that says something like "Upload a PDF to generate your interactive interview." And then right below this section, we can create a new component that's going to be within the `components` folder. And I'll call it `UploadForm`. So create a new file called `uploadform.tsx`. Run `rafce`. And then we can use it directly within this new page. That's going to be `<UploadForm />`.
Now, do you have an idea why have we created this form as a standalone component? Even though most likely we won't use `UploadForm` in some other places. Any guesses? Well, that's because the form will have to use some browser functionalities, which means that I already know that we'll have to turn it into a `'use client'` property or component so that it gets rendered on the client side. That way, we don't have to have the entire create new book page on rendered on the client side, which we could have done as well by using the `'use client'` here. But this way, this renders first through the server and then on the client, we render only the form. That's more efficient.
So to get it implemented, you can head over to shadcn and simply search for forms. You'll immediately see that you can use React Hook Form or TanStack Form. In this case, I'll go with React Hook Form. And in this guide, we'll take a look at building forms with React Hook Form. This form will leverage the React Hook Form for permanent flexible form handling. You get the field components and controllers, and that's going to look something like this. And creating forms isn't easy. I think at one point they even used to say that in shadcn docs that building forms isn't super easy and it's the thing that we have to do within every single application. So we could go ahead and follow all the docs, but that would definitely take some time until we get it done. So, this is another one of those perfect use cases where we can leverage AI to our advantage to read from the documentation and build out the form for us.
So, go ahead and open up your AI agent of choice and start a new chat. That's another pro tip. Whenever you start implementing or working on a new feature not related to the one we've done before, you always want to open up a new chat. That way, you clear the context and give it a clean slate to start working on the next feature. First things first, I'll take a screenshot from the final website, which you can do as well. Once again, this link will be shared within the video kit link down in the description. I'll do a screenshot of the entire form. And then you can simply drag and drop it in. And now we can actually tell it: "Go ahead and develop this." That's how I would normally do it with a very simple human-written form. But since I want to make sure that both you and I have the same exact output with maybe little differences, I'm going to provide you with a more detailed prompt that you can use so that our output is more or less the same. It's also going to be in the video kit link down in the description and it's going to read like this: "Build a book upload form with a warm literary aesthetic using these existing CSS classes. The form has five fields stacked vertically. First, we have the PDF file upload, which is going to be a drop zone area using the `upload-dropzone` package with a dashed border, upload icon, and then 'Click to upload' text and so on. Then there's also going to be a cover image upload. Same thing. Then there's going to be a title input, which is going to have some kind of a placeholder, the author input, and finally the voice selector. And there's a submit button. Use shadcn UI form components with React Hook Form and Zod validation, and wrap everything in a new `book-wrapper` container. Show a loading overlay when submitting."
This is good because now we're sharing both the visual aspect of what we want to achieve as well as the text. So it's going to have that much more context that you throw at it. So press enter and let's see how well it does it. I'll put the browser on the left side so we can monitor the changes that it's making in real time. Installing packages, checking out our `globals`, and then it'll start creating all of those inputs. Occasionally, you might want to give it additional access, but when you do, it'll just keep doing its own thing. Check this out. It just came up with a plan. Now, it understands what the goal is, and it'll proceed checking whether shadcn is installed, and then it'll install additional shadcn components that we don't yet have access to. So, I'll just allow it to do that, and then it'll be able to proceed. And we can also monitor the changes because first it'll set up the structure of our application. Specifically, the types and everything else that it needs for our schema, such as the book file, the cover image, the title, the author, and finally the voice. That way, the application is heavily typed so that whenever we get back to it later on, we can know exactly which fields does each one of these inputs accept. And now, very quickly, it'll modify the form and we'll be able to see the changes in real time. What I love about it is that it's also checking for linting errors. Not just code mistakes, but also whether the code is not clean enough. That's the point of these more quality AI agents like Juny is that they ensure that the code that they write ends up being maybe even cleaner than what you would initially write.
There we go. It has implemented the book upload form with all the file fields and it'll now provide the changed files. And the only thing you have to do is reload. And check this out. I mean, I I can't make this up. This is the final version, and this is what we just did in less than a minute. This even seems better because we can drag and drop things in. I love it. But there seems to be a little error right here saying "a tree hydrated, but some attributes of the rendered server HTML didn't match the client properties." And if I reload, the same thing happens. This is actually the perfect example showing you that it's not going to be perfect, at least not yet, right? These models are getting better and better, but fixing these errors is also super simple. So, I'll copy the error and just paste it here without even typing anything and press enter. Your AI agent should be able to figure out what the issue is and fix it within the codebase. It'll read through all the relevant files and then come up with what it believes to be the issue and fix it. "The file uploader component was updated to use a different form control structure while maintaining its file upload and removal functionality." You can even open it up and then see the diff between those components so you know exactly what changed. With that in mind, here is the summary: "It resolved a React hydration error by ensuring consistent client-side rendering and fixing redundant component structures." If you go back and reload, the error is gone.
The only thing that remains for us to do is to check the code that it implemented in detail and to fully understand it. So let's do just that. I'll head over into the `app/root/books/new/page.tsx`. Everything remained the same. But in the `uploadform.tsx` component is where the magic happened. As shadcn likes to put it, forms aren't simple. So of course there's going to be a lot of imports from shadcn, from Zod, which we use for managing our schemas, as well as from TypeScript, and then we also created some additional components for file uploading, voice selectors, and loading overlays. So we're starting with creating two state fields: one for the `isSubmitting` property so we can properly display loading if we are submitting, and one figuring out whether we are currently mounted or not. Then we display the actual form, which has an empty `title`, `author`, and `voice` properties. Later on, we'll of course have to add more fields to this so it actually understands. So our database and front end can actually
Talk to each other. Then there's the onsubmit, which simply console logs the value and simulates the submission.
And then finally, there is the form. If we're submitting, we should show the loading overlay. Otherwise, show a div with a form where we start with a file uploader component to which we pass all the necessary properties. Then, after the file uploader, we have another file uploader for the cover image. Finally, a form field for the title input and also the form field for the author, and then after that, a voice selector. Finally, there's a submit button.
So, we've got the UI of a seemingly finished submit form with real error handling. So, if you click "Begin Synthesis," it'll tell you that it can't upload without the right title and author. And you can also select from these different voices.
But, of course, now that we know the format of what we want to build, such as the fact that we need a PDF, a cover image, a title, and the author name, and the assistant voice that we want to choose, now we can form our database structure in a way to accept all of these fields properly and store them into the database so that later on we can display them directly within our library. So, we'll be able to start working on the database structure next.
But considering the fact that we have already implemented huge parts of the UI, including the homepage as well as the "Add a New Book" page and the authentication in the navbar, I think we are more than ready to submit this project over to GitHub so that we can continuously track upcoming changes, so that if something goes wrong when using AI, we can always revert back to the previous commit. This is super important.
So, head over to github.com/new and create a new repo and call it something like "bookified" and just create it. So, run `git init -b main` to make that the primary branch. And then now we're going to do something a bit different. `git commit --allow-empty -m "Initial commit message"`. This will create an empty commit with no code. I'll explain soon why we're doing this. Then, follow the rest of the steps. `git remote add origin` and copy your URL, and then `git push -u origin main`. If you push this and reload, you'll see that now we have an empty GitHub repo.
What this allowed us to do is to push the current code to a new branch. So, run `git checkout -b setup-and-ui`. And let's call it "setup and UI." Then, you can run `git add .` to commit all the files. `git commit -m "Set up the app and implement homepage and new book UI"`. And then run `git push -u origin setup-and-ui`. And what is the name of our branch? It was "setup-and-ui." And press enter.
Now, this will push the current code to a new branch. So, you'll see that we have a new PR, which GitHub will immediately recognize and allow us to open up a new PR. But before you do that, there is one super important step that you have to go through, and that is to set up Code Rabbit before so it analyzes that PR and flags any potential errors or inconsistencies.
So, click the Code Rabbit link down in the description to be able to follow along and see exactly what I'm seeing, and then click "Try it for free." It is always free with a 14-day free usage of the Pro plan. So, just so you know, you don't have to enter the credit card details, and you will always remain on the free plan. You can authenticate using GitHub, and once you're in, you'll have to add your repositories so you can find it. So, just go to "Add repositories," and then you'll have to add Code Rabbit as the app to your GitHub and give it access either to all repos or only some of the repos, such as this one we created. But you can totally go with all. Once you do that, you can just search for your application here. That's going to be "bookified." This means that it is tracking it and we'll be able to get a review directly on our PR.
Oh, and also, issue planning is now out in early access, which means that you can now connect your issue tracker, such as Jira or Linear, and then Code Rabbit will help you plan your upcoming features, which then your coding AI agent of choice will be able to develop. As you can see, the whole industry is moving towards this new way of developing applications. And I know it can be a bit scary, but it is the future. So, you'll code faster, and you'll be able to actually use your brain on more complex features where it's needed, while at the same time having Code Rabbit cover your or your AI agents' ass when you make a mistake, if AI agents even have asses.
But yeah, it's currently going through this PR. So, let's give it a minute or two and let's see what it has to say about our setup. This is a fairly large one, 51 files changed. So, let's see what it has to say about them.
And in just a couple of minutes, the PR review is now in. First, we get a nice walkthrough letting us know what even happened in this PR. We're setting up a complete Next.js project scaffolded with TypeScript, Tailwind, Clerk, Shadcn, form handling via React Hook Form and Zod, and a book library feature with PDF upload and voice synthesis. We get an explanation of different pages, which might be more useful if you're checking somebody else's PR so you understand what's happening where. This PR would take a normal human being about 35 minutes.
And let's see what it has to say. There are 11 nitpick comments, which are smaller ones, typically talking about something that can but doesn't necessarily have to be fixed. Such as, currently we have a `setTimeout` on form submission, which Juny added, so we can replace it later on. And yeah, that's just as a to-do that later on we'll be able to fix.
But where the real magic happens is within the real review. And in this case, we got plenty of comments to go over. So, let's go through it together and see whether we can improve our codebase and at the same time learn something to become a better developer through the process of PR reviews. I mean, this just reminded me when I was working as a junior developer, I would learn the most through the pull request reviews that my senior developer provided me. So, in this case, Code Rabbit can act as our senior dev reviewer, providing us real tangible feedback.
Starting off with a minor potential issue where Code Rabbit noticed that Juny actually pushed some additional files which don't necessarily need to be pushed over to GitHub, such as this "Juny" folder. So, these can totally get added to `.gitignore`. There are multiple ways in which we can apply Code Rabbit's fixes. One is to maybe go ahead and do it manually. The other is to just copy the prompt for an AI agent to fix it. Let me show you how that works. You copy it. You open up Juny and you paste it in. And now it'll figure out what this rabbit is complaining about and fix it. I'll give it access to the `.gitignore` file. And this change should be pretty simple. It just added the `juny.xml` and `material-theme-project.xml` to `.gitignore` and then removed them over from GitHub.
Then we can move over to the second one, where we have to remove a plugin-specific file containing a developer user ID. This is interesting. So, in this "material-theme," it looks like some personal info got shared, and Code Rabbit caught it. So, thankfully, we have already added this file to `.gitignore`. But now we also have to remove it from the Git history. So, this AI cannot be found in Git history.
Then we can move on to the next one. Also a minor one, but "Verify subtitle copy." This is interesting. The subtitle says, "Upload a PDF to generate your interactive interview." Is "interview" the intended term? I love this because never else do we mention an interview, but maybe it should be more like "interactive reading experience." And you know what? I agree 100%. So, I'll just search our codebase for "interview" and switch it over for "interactive reading experience." Much better and much more on-brand with the bookified idea. Let's continue.
Here we have a minor CSS issue. And then there's another minor one, but yet a real issue. I used `figure` and `figcaption` components trying to pretend that I'm smart and I know how to use them, but accidentally I put the `figcaption` outside of the `figure`. So, the proposed fix is right here. We just got to bring it in, which is exactly what I'm going to do. I'll search the codebase for the `figure` right here and then put the `figcaption` right within it and verify that that didn't break anything. It looks like we're good. So, one more issue done.
Now we have a major one. "Drop zone isn't keyboard accessible. The clickable div triggers the file input on click but has no `tabIndex`, `role`, or `onKeyDown` handler. Keyboard-only users cannot activate this picker." This is interesting and definitely an addition that we can add later on in case we want to make this a production-ready application. There's another one which is going to be fixed later on once we start uploading real files. Another minor one for the role and a potential critical issue saying that we have invalid Tailwind syntax. This is a huge one. It says, "The navbar header will have no background, causing content to show through when scrolling." And I think that's exactly what happens. If I go back right here, check this out. The navbar has no background. So, we can fix it by properly denoting this variable that's going to be right here within the header that is in the navigation bar. So, we'll just have to change this part with the proper syntax. And after pasting it, you can see that our IDE is saying that there's an even simpler way for us to write it. So, I can say "replace," and this should do the same thing. So, back into the browser, if you reload, you can see that now the navbar actually has a solid background.
There is another minor issue which is a typo. Another issue with the `useForm` which is totally okay. We're going to improve this later on as we continue developing our form. Same thing for this major one: "Submitting will permanently lock the form." That'll be fixed once we implement the real logic. Same thing for this one and this one.
This is a nice refactor suggestion where in Zod, right here, if we head over to `lib/zod`, we have the total file size set to 10 megabytes, which you can see it right here. But within our constants, we already have a `maxFileSize` or `maxImageSize` in this case, which is also set to 10 megabytes. So, we already have a variable. Why not use it? Right here, instead of these numbers, we can just refer to the `maxImageSize` by importing it from constants. Another great check. So, now if you change it in one place, it's going to change in all the places.
And with that in mind, we came to the end. So, let's push all the additional fixes we implemented by running `git add .`, `git commit -m "Implement Code Rabbit suggested fixes"`, and then run `git push`. Back on GitHub, these changes will automatically be recognized, and we can go ahead and merge it to `main` so that we can dive straight into the database setup of our application.
To do that, I'll check out back to the `main` branch, run `git pull` to pull all the latest changes, and then create a new branch and check out to it by saying `git checkout -b database-setup`. So, in the next lesson, we can dive right into it.
To get started developing our database structure, head over to MongoDB Atlas and create a new account. You can just sign up with Google, and once you're in, you can either join one of the existing projects or create a new project within your organization. You can call it something like "Bookified" and click next. Once you create your project, you'll then need to create a cluster. A cluster is essentially your database. The free one is going to be more than enough. And for the region, choose the one that is closest to you and click "Create Deployment." Then, you'll be given your username. You can enter something like your first or last name and then a password, which you can go ahead and copy and create a database user. Then, you'll be able to choose a connection method, and in this case, we'll go with "Drivers" to hook up our Atlas data through Node.js. So, you'll be given a connection string, which you can copy and click "Done."
Then, back within your application, you can install the two necessary packages to run this by running `npm install mongodb mongoose`. And while that is installing, you can head over into your `.env.local`. Here we have two Clerk variables. And then right below it, we can have our database environment variables, where the most important one is the `MONGODB_URI`, and we'll set that to the string we just copied. Make sure it includes the username and the password.
Now that the packages are installed and we have our connection string, you can create a new folder right in the root of our application, which you can call `database`. And everything that has to do with database setup, we can implement right within here. Starting with a file called `mongoose.ts`. This is where we're going to import Mongoose, which we're using as an ORM for our MongoDB database, essentially allowing us to choose how our schemas are going to look like. And then we can also get access to the `MONGODB_URI` by getting it from our environment variables. And it looks like WebStorm suggested a very nice implementation right here to check whether we have it before we proceed. If not, we can just throw a little error saying, "Please define the MONGODB_URI environment variable."
Now, since we're using Next.js and specifically accessing our database through server actions, that is a bit different than from having a persistent server connection. In this case, we have to create a cached connection because the connection to our server gets destroyed on every new request. So, instead of recreating that connection to the database every time from scratch, instead, we can create it once and then use the one from cache. To do that, we can declare a new global variable where we can say `var mongooseCache`, and it'll consist of a connection of a type `Mongoose | null` and a promise of a type `Promise<Mongoose | null>`. And then we can get that cached connection from global storage. This is basically where we're saving it. So, I'll say `let cached = global.mongooseCache || {}`. Like this, or if it doesn't exist, we're going to create a new one, which is going to be fully empty. And then we are ready to create a function that'll connect us to the database. So, `export const connectToDatabase = async () => { ... }`. And we can open up a function block right here. First things first, we can check if a cached connection exists and return it. Pretty simple, right? No need to reconnect. But if we're not already connecting, we can start the connection process by saying `cached.promise = mongoose.connect(MONGODB_URI, { ... })`. And I'll provide just one additional setting to it, and that's going to be equal to `bufferCommands: false`, which allows us not to queue commands if the connection is slow. And then I don't believe we have to use the `.then` on this. It'll automatically add it to the promise. Finally, we can open up a `try` and `catch` block right here by saying `try { ... }` and we can also get the `catch { ... }`. I'll pull these two into the `try`. So, here we set `cached.connection = await cached.promise;`. And then we don't have to return it. But in the `catch`, we can simply say `cached.promise = null;` `console.error("DB connection error:", e);` and then we can also throw that error. Perfect. And this going to be just `console.log`. Finally, we can add some kind of a `console.info("Connected to MongoDB");` and return `cached.connection`. So, essentially, how this function is working is it's checking whether we already have a connection. In that case, we simply reuse it. But if we don't have one, then we create a new Mongoose connection and save it for later. Finally, if we're not already connecting, then we start the connection process. Cool. So, we can now get connected to the database. And now we are ready to start creating the schemas for our application.
So, head back into the `database` folder and create another directory called `models`. Within `models`, we can create our first model, which is going to be `book.model.ts`, the model for the book. Thankfully, within our `types.d.ts`, we already have an interface of a book that looks something like this. And we basically have to have all of these fields in our database as well. Still, I'll go ahead and create it manually so you know how to create models in the database, and then after that, we can create additional models for the application using AI.
So, first things first, back in `book.model.ts`, I'll simply paste this interface right here, just because it's going to be easier to figure out which fields do we need. You can start by defining a `bookSchema` at the top, and you need to say `new Schema<IBook>({ ... })`. That way, both TypeScript and the database are going to be on the same page. And then within it, you can start defining different fields. So, I'll simply put these fields up here. We don't have to manually declare the `_id` because it's already there. And of course, instead of semicolons, we need to separate them by just commas because we're within an object. And another thing, we can't just define TypeScript types right here. We'll have to define types how Mongoose does them. So, starting with `clerkId`, this is going to be simply an object where we define the `type: String` and `required: true`. And then we'll be able to do the same thing for most of the string-related properties. The `title` will be the same. The `slug` will be the same, but that one will also have a `unique: true` property set to true. And it'll also have a `lowercase: true` property set to true. And we'll trim all the extra spaces. Then for the `author`, that's also going to be of a type `String`, `required: true`. `persona` is optional, which means that it will not have `required: true` properties set to true. `fileUrl` of course is important, and that's going to be an `String`. `fileBlobKey` is also going to be the same thing. This is used for deleting the files once we upload them. `coverUrl` is going to be of a type `String`, but not required. `coverBlobKey` going to be the same thing, not required. `fileSize` is going to be a `Number`. So, not a string, but rather a `Number` and a `required` one. And finally, we're going to have the `numberOfSegments` that we're going to split the book contents into, which is going to be a type of `Number`, defaulting to zero. And then we don't have to define the `createdAt` and `updatedAt`. Instead, right outside of the declaration for the fields, we can say `timestamps: true`, which will auto-add the `createdAt` and `updatedAt` fields. This schema should be coming from `mongoose`. And as soon as you do that, you'll see that it'll no longer complain. And we now have this model, which is the same as the TypeScript interface. So, both within our application while developing it and later on when trying to figure out which fields does a book have, we will know exactly which ones does it have.
Finally, to prevent duplicate model registration in Next.js's hot reload, we need to say `const Book = mongoose.models.Book || mongoose.model<IBook>('Book', bookSchema);`. So, we get the one already defined, or if it doesn't exist, then we create a new one. So, that's going to be `model` of a type `IBook`, and then we give it a name and a schema. So, what this line is doing is it's saying if under `mongoose.models` you can already find a model of `Book`, then just use that one, and if you can't, just create a new one. And then we can just `export default Book;`. And this is it. This is how you define a model in Mongoose. So, later on, we can build book documents following this book model within our database.
And as I said, since we already have the types right here of `IBookSegment` and `IVoiceSession`, which are going to be two additional models, which we're going to use within our codebase, instead of creating these manually, let's just go ahead and ask Juny or your AI agent to do them for us. I'll just copy their names. So, that's `IBookSegment` and `IVoiceSession` within `types.d.ts`. And I will open up Juny and then open up a new chat and tell it, "Following the structure of `book.model.ts`, also create models for these two `IBookSegment` and `IVoiceSession` TypeScript interfaces." And press enter.
Now, if you open up your file tree, you'll be able to see that Juny will automatically create two new files as soon as it understands what it needs to do. So, check this out. `bookSegment.model.ts` and `voiceSession.model.ts` got created right away. And you can already open them and see them right here. That's it. This was incredibly quick. Let's just go over them and make sure they're implemented properly.
What we're using book segments for is once we upload the PDF, we'll have to split it into additional chunks. That way, we'll be able to share all this info with VPY so that our user can speak with the entire book. We have the `clerkId`, which is required. Then we have the `bookId`, which is actually a reference to the book model. It'll be `required: true`, and we'll make it `indexable`, which means that we'll be able to search through different segments. Then it'll have a `content`. It'll have a `segmentIndex`, which will also set `index: true`, a `pageNumber`, which will also be `indexable`, and a `wordCount`.
And now for this `bookSegmentSchema`, we'll want to index it. This is going to be used for efficient segment retrieval, which means that it can go through it by `bookId` and by the `segmentIndex`. And I'll set it to `true`. And it'll be able to do the same thing by the `pageNumber`. And we also want to do the same thing for the book content. So, I'll say `bookSegmentSchema.index({ content: 'text' })`. This allows for block-scoped text search. This part is crucial to how our application works. So, I want to make sure to explain it properly. But basically, when VPY reads a book aloud, it fetches segments in order. And this index makes that lookup instant instead of scanning every segment in the collection. And this unique part right here makes sure that no duplicate segments are stored. Now, this `pageNumber` enables fast jumping to specific pages to query the info within the book. And this one with the `content: 'text'` is the one that powers the VPY search tool. So, when a user asks, "What does the book say about this topic?" then that'll hit VPY's API, which will run a MongoDB text search on a specific content but scoped just to a specific segment. Without this index, every search would scan all segments across all books. So, these three lines are crucial to how our application works. And as we start implementing voice functionalities, it'll start making more sense exactly what do they do. So, maybe you're reading a "Clean Code" book and you want to know a bit more about writing atomic functions. If we didn't have this segmentation based on text, it would have to read through the entire book to find the related part. But if we split it into segments, it only has to find one segment that talks about atomic functions and then dive deeper into it. LLMs and AI tools use this logic when going over large PDFs.
And we can do something similar with the last model, which is the `voiceSession` model. We have a `clerkId`, will also make it `indexable`. Then we have the `bookId` reference. The `startedAt` field, which we can maybe set the default to `Date.now`. `endedAt` type of `Date`, `durationInSeconds`. I'll make that required. And finally, we have the `billingPeriodStart`. This is related to how we'll implement billing. And I'll make this also `indexable`. Basically, this is used for tracking the quota. Like, "We started on 2nd February. So, we want to start charging again on 2nd of March." And we can implement one index by saying `voiceSessionSchema.index({ clerkId: 1, billingPeriodStart: 1 })`. So, that way, we'll be able to quickly get access to the information of which user we need to charge for a specific subscription. Perfect. Now we have the three models that we'll use within our application. And very soon, we'll be able to put them to good use.
Now that we have the book model, let's go ahead and create a book. We can do that by creating different actions. That's going to be within `lib`. And within `lib`, you can create a new directory called `actions`. And within `actions`, you can create a new file called `book.actions.ts`. And then within it, we can create different actions that have something to do with the book model. For example, the first one that will make the most sense is the one to create a book. So, let's `export const createBook = async (data: IBook) => { ... }`. And that is equal to an asynchronous function that accepts different data within it. That's data for creating a book coming from `types`, and that mostly includes, well, everything that a book includes without its ID. And then we can open up a new function block. Most of these server actions will have a `try` and `catch` block, so we can properly catch errors if they exist. And if they do, we'll simply return the error by saying `console.error("Error creating book:", e);` and then display the actual error, and then return an object that'll include `success: false` as well as `error: e`. But if we don't have any errors, then we are ready to accept this data coming through the form and create a new book.
So, to do that, first things first, we have to connect to the database. So, say `await connectToDatabase();`. This is our function that we created not that long ago in that `mongoose.ts` file. And once we connect, we are ready to first generate a book slug, which will be based on that book's title. So, I'll say `const slug = generateSlug(data.title);`. This is a function that we can define right here. I'll define it above by saying `const generateSlug = (text: string) => { ... }`. And as you can see, this can get autocompleted for me by using WebStorm. But we could also use a real title to do it properly by maybe replacing some file extensions like `.pdf`, converting it to lowercase, removing white spaces, and so on. So, instead of having this function right here cluttering our server actions for the book, we can move it over into the `utils` file, where you can create and export the `generateSlug` function.
Now, throughout this video, we'll have to create and use a couple of these other utility functions. So, you can find the full `utils` file in the video kit link down in the description. Copy it and paste it here. And one of them is going to be the one to auto-generate a slug that takes the text, which is the title of the file you upload, replaces the file extensions, converts it to lowercase, removes white spaces, and then uses regular expressions to remove special characters, spaces, and underscores with hyphens, and removes the leading or trailing hyphens. Uh, working with the regular expressions used to be such a pain, but nowadays, uh, again, AI just handles this absolutely perfectly.
So, now if we go back here, we can just use `generateSlug` coming from `lib/utils`. And to it, of course, we need to provide the title of the book, and that title will be right within `data.title`, the title that we're passing right here to the `createBook` function when we call it from the form. And if it isn't clear up to this point where we're going to call it from, it's going to be from within `components/uploadForm.tsx`. So, this button on submit of the form, when we get all of these form inputs, we're going to call `formSubmission` right here with all the necessary values.
Okay, now that we have the slug, before we actually generate the book, we want to check if a book with the same slug already exists in the global library. We can do that by saying `const existingBook = await Book.findOne({ slug }).lean();`. And I'll also say `lean()` and make sure that the book is coming from our book model. Then I'll check if we have an existing book. In that case, we will return `success: true`, we will return the data, which is just going to be the existing book, and I'll say `alreadyExists: true`. So, maybe on the front end side, we can redirect it somewhere else. But now, whenever we try to share back the data from a server action back to the front end, you cannot just pass it like this by passing a huge JavaScript object. First, you need to serialize the data, which means that you have to stringify it by running `JSON.stringify()` and pass it into it. And then you have to parse it one more time. You have to do this whenever you're passing big or complex objects from server actions back onto the front end. So, instead of doing this every single time, I created a utility function called `serializeData`, which you can just use right here, `serializeData(existingBook)`. That's going to be coming from the `utils`. And as you can see, it does the same thing that we've done right now. "Serialize Mongoose documents to plain JSON objects. This basically removes the object ID, the date fields, and so on that JavaScript doesn't know how to work with into regular JavaScript objects." Perfect.
But what happens if we don't have an existing book? Well, later on, we'll have to first check whether the user uploading the book is crossing some kind of limits. So, I'll say `// TODO: Check subscription limits before creating a book`. That's going to be a task for us later on once we implement subscription checking. So, I'll add it as a to-do. And then finally, we are ready to create a new book by saying `const book = await Book.create({ ...data, slug, numberOfSegments: 0 });`. We're gonna spread the full book data, attach a slug, and set the total number of segments, at least at the start, to zero. And then we can return `success: true` and `serializeData(book)`. Perfect.
Now we can create a book. But a book is nothing without its segments or without its contents that MongoDB can actually go through and search when we ask it a question. So, let's create another function and export it called `saveBookSegments`. And it'll be an asynchronous function that'll accept a `bookId` of a type `string`, a `clerkId` so we know which user we're saving those segments for, and then the `segments` themselves, which are going to be of a type `TextSegment[]`, which we have to import from the `types`. Then we can open up a new `try` and `catch` block. In the `catch`, we can simply `console.error("Error saving segments:", e);`. But if we can't save segments, then we also have to delete the book and any partial segments that might have been stored to the database before it failed. So, right here I'll say `await BookSegment.deleteMany({ bookId });`. And basically, we'll delete all of them for that specific `bookId` because each book segment has a `bookId`, so we can remove it. We also have to delete the book itself. So, I'll say `await Book.findByIdAndDelete(bookId);`. And we want to delete a book with the specific `bookId` that we uploaded in the first place. So, for that, I'll add a little `console.log("Deleted book segments and book due to failure of saving other segments.");`.
So, now let's try to save other segments. And we can do that by first again connecting to our database. We have to do that at the start of every server function. Then, since this process takes a bit of time, we can add a `console.log("Saving book segments...");`. And then we can first prepare the segments for insertion by saying `const segmentsToInsert = segments.map((segment) => ({ ...segment, clerkId, bookId }));`. And that's going to be equal to `segments.map` where we get each individual segment. And for each one of these, we can automatically return the `clerkId`, the `bookId`, the `content`, which is going to be coming from `segment.text`, the `segmentIndex`, so we know how to get to it, which is going to be equal to `segment.segmentIndex`, and now noticing that we can destructure some of the things from these segments, so I'll just destructure the `text`, the `segmentIndex`, the `pageNumber`, and the `wordCount`. And how do I know that each segment has these fields? Well, if you head over to our `TextSegment` type, you can see that these are the exact things that we're going to pass once we're extracting the segments over from our book. So, if you extracted them, that means that you don't have to spell it out. You can just say that the `content` is set to `text`. `segmentIndex` is basically just itself, `segmentIndex`. Same thing for `pageNumber` and the `wordCount`.
And now that we've prepared the segment, we can bulk insert all of them by saying `await BookSegment.insertMany(segmentsToInsert);`. Finally, we can update the book with the total segments count by saying `await Book.findByIdAndUpdate(bookId, { numberOfSegments: segments.length });`. Finally, we can do a little `console.log("Successfully saved segments.");` and then return an object that says `success: true` and the `data` can be set to an object that says `segmentsCreated: segments.length`. So, now we're not just saving a book, we're actually extracting the PDF contents from the book and then attaching them to the book.
And the final server action which we need for now is to check whether the book already exists before we try creating it in the first place. So, I'll say `export const checkBookExists = async (title: string) => { ... }`. And this will be pretty straightforward. We'll open up a `try` and `catch` block. In the `catch`, we'll simply run a `console.error("Error checking book exists:", e);` and return `{ exists: false, error: e }`. But in the `try`, we first need to connect to the database, then generate the slug for the book that we're trying to create, similar to when we're actually creating it. And then we need to try to find the existing book with that title. And if that existing book exists, in that case, we can return `{ exists: true, book: existingBook }` without ever creating it in the first place. Similar to what we had in the actual book creation. Great. Now we have a couple of these server actions, and that's super important. These are server actions, which means they're going to be ran on the server. So, we have to add the `'use server';` directive at the top of the file.
And let's go ahead and use them within the `uploadForm`. So, head over into your `uploadForm` where we have the `onSubmit` function. First things first here, we want to check if a user is logged in, the user who is trying to upload the book. So, I'll say `if (!userId) { ... }`. And what do you think where can you get the user ID from? That's going to be simple, it's going to be coming from Clerk. So, right here I'll say `const { userId } = auth();`. Then, once we have the user ID, we can check whether it exists. And if it doesn't exist, we'll simply return a toast: `toast.error("Please login to upload books.");`. But where is this toast coming from? It is coming from a package called `sonner`. So, we can just import it right here by saying `import { toast } from "sonner";`. And that `sonner` is a package that we have to install by running `npm i sonner`. Once again, how did I know that? Well, if you head over to Shadcn UI and search for "toast," oh, the toast component has been deprecated. Use the `sonner` component instead. There we go. A little toast. You can install it by running `npx shadcn-ui@latest add sonner` not by manually installing it like what I did. And then once it gets installed, you have to add this `Toaster` component within the root layout below the `main`. So, let's do just that by heading over into our `app/layout.tsx` right below the `main` or in this case below the `body` and render the `Toaster` component, which you now know is coming from `components/ui/sonner`. And as soon as you do that, you can now go back, import `toast` from `sonner`, and if a user doesn't exist, it'll actually display the error message. It is as simple as that. And we can also exit out of this function because you cannot create a book.
But if the user is logged in, we'll set the `isUploading` to `true` so we can start the loading process. Later on, we'll also implement PostHog here to be able to track the actions that the users are making. In this case, track the book uploads. But more on that soon. Here we actually want to start with the upload process. So, I'll open up a new `try` and `catch` block. You know me, I love my good `try` and `catch`. And sometimes there's even going to be a `finally` aspect to it where we can set the `submitting` state to `false`, whether it succeeds or fails, it needs to stop loading, right? In the `catch`, we can simply `console.error(e);` and then maybe also display an error toast: `toast.error("Something went wrong. Please try again.");`. But in the `try` is where the magic happens.
Here we need to first check whether a book exists. So, we can say `const existingCheck = await checkBookExists(data.title);`. This is a server action we created, and we only need to provide the title into it to be able to know for sure. Then, if `existingCheck.exists && existingCheck.book`, that means that we got back the book that already existed. We can just return a `toast.info("Book with the same title already exists. Please try a different title.");`. Or even better, we can say `toast.info("Book with the same title already exists.");` and it doesn't have to be an error toast, it can be an info toast. After which, we won't return yet, but rather we will reset the form and redirect the user to that already existing book. That's a significantly better user experience. So, for that, we have to get access to the router functionality by saying `const router = useRouter();`. And this `useRouter` is coming from `next/navigation`. And then you can use the `router.push()` method to push over to `/books/${existingCheck.book.slug}`. But of course, this will have to be a template string. And then we need to rename this `values` at the top into `data` because that's where we expect to get the title. And now, why is it complaining that this might be undefined? Let's see what we're returning from the `checkBookExists`. We always need to return the `exists` property and the `book`, or if a book doesn't exist, we also need to return something. So, we'll return `{ exists: false }`. So, now it no longer complains about `exists` because it's there, but it's saying that the `book` doesn't exist. That's because I called it `data` here, but it should have been `book`. So, if we fix it here, you can see that it no longer complains. And finally, we exit out of this function and don't proceed with creating the book. Good. So, now we handle the case if we're trying to upload a book that already exists.
Finally, we're getting ready to upload the book. To be able to achieve that, we first have to extract the file title, which is equal to `data.title.replace(/\s+/g, '_').toLowerCase();`. And in this case, we can replace all empty spaces with underscores and also make it to lowercase. And we also need to extract the PDF file itself by saying `const pdfFile = data.pdfFile[0];`. Because we only want to get the first one. Then we want to parse that PDF file by saying `const parsedPdf = await parsePdfFile(pdfFile);`. And this is another one of those functions that you can find within the `utils.ts` file that I used AI to write. And this one first reads the file as an array buffer, then it loads the PDF document, it renders the first page as a cover image in case we don't upload our own image, extracts it into a canvas, and then returns it to the cover data URL. After that, it needs to extract the text from all the pages by heading over all the way from page one to the final page, filtering over through all of the strings on the page, joining them into a full text, and then finally splitting it into segments. The `splitIntoSegments` function splits the text contents into segments for MongoDB storage and search. I was doing a lot of prompting with AI to get this right. But after a lot of research, it turns out that the maximum words per segment should be about 500.
So, here we get the parsed PDF. We can then do a quick check to see if maybe none of the text was extracted from the PDF. If maybe a PDF contains only images or text that got screenshotted. In that case, we can say `if (parsedPdf.content.length === 0) { toast.error("Failed to parse PDF. Try again with a different file."); return; }`. Else, we're finally ready to upload the PDF. And to do that, we'll use a package called `vercel-blob`. You can just install it by running `npm install @vercel/blob`. And that allows you to basically upload your objects to Vercel blob store and review their metadata, download them, or delete them from Vercel storage. So, let's go ahead and install it by running `npm install @vercel/blob`. And then you can say `const uploadedPdfBlob = await upload(pdfFile, { ... });`. Let's make sure it's coming from the right place. There we go. `import { upload } from '@vercel/blob';`. And then into it, you can pass the `fileTitle`, the `pdfFile` itself, and then some additional options such as `access: 'public'`, `handleUploadUrl: '/api/upload'`, and then finally the `contentType: 'application/pdf'`.
Now, like we uploaded the actual PDF, we want to do the same thing with the cover image, whether the one taken from the PDF itself or the one that we want to upload. So, I'll say `let coverUrl: string;`. And then we can check if `data.coverImage` is already there. And if `data.coverImage.length > 0`. That means that it exists. Then we can try to extract the image that the user has provided. So, I'll say `const coverFile = data.coverImage[0];`. And then we can finally upload it to Vercel by saying `const uploadedCoverBlob = await upload(coverFile, { ... });`. We can then pass the `fileTitle`, the `coverFile`, `access: 'public'`, `handleUploadUrl: '/api/upload'`, and the `contentType` will be set to the type of the actual image, which is going to be `coverFile.type`. But instead of just calling it in the same way that we call the PDF, we can call it a bit differently. I'll give it the same name as the book, but then I'll also say `_cover.png` like this. And then we can set the `coverUrl` to be equal to the `uploadedCoverBlob.url`.
But if the user hasn't provided this image, that means that we can generate it from the PDF's first page. So, I'll say `else { ... }`. And try to access it by saying `const response = await fetch(parsedPdf.coverDataUrl);`. We want to get access to the parsed PDF. And then get the cover image blob by saying `const coverBlob = await response.blob();`. And then want to upload it by saying `const uploadedCoverBlob = await upload(coverBlob, { ... });`. Same thing that we've done before.
Back in the browser, I'm noticing that we have an issue, and that is that we haven't installed the `pdfjs-dist` package, which we used for parsing the PDF. So, just run `npm install pdfjs-dist`. I think this was in the browser for a long time, but we didn't actually check it while we were writing code. So, now if you go back, we still have one issue talking about this "display canvas with this DOM matrix not available." To be honest, I'm not really sure what this issue is all about.
about. And before I would just go ahead and Google this issue. Let's go ahead and give uh Stack Overflow some traffic because they lost so many 3 years ago. One question. And if you went through it, most likely somebody in the answer would yell at you saying that you should not have this issue.
So instead, what's better is to just feed this over to AI. So I'll open up Juny right here and ask it what this error is all about by simply pasting it in. And it has access to our codebase. So it's that much better than Stack Overflow, which doesn't have access to it. And very quickly, it provides a fixed modified lib util to remove a tople import. instead it imported it dynamically. So now if I go back you'll see that the error is gone. Now we can head over to this add new. There we go. No errors. That's good.
And hopefully you understand a bit more about all of these server actions that we're creating. Once you upload a PDF so much more happens than what you can see on the screen. Before we even save the book, we have to extract the segment from its contents and also the cover image. you can upload it manually or if not we can extract it from the PDF itself. Only after that is done we can proceed with trying to upload the book.
So I'll say const book is equal to await create book and to it we can provide the data for the book containing the clerk ID which is going to be the user ID the title which is going to be under data.title title, the author under data.author, the persona under data.persona. If you're not sure what that is, that is the assistant voice that we're choosing. We're also going to have a file URL, which is going to be the uploaded PDF blob, the file blob key so we can remove it in case the user decides to change the image. That's going to be the uploaded PDF blob.path name. And finally the cover URL as well as the file size which is going to be set to the PDF file dot size and I believe that should be everything we need to provide into the create book. Yep, that is correct. But the file URL should be the URL. So uploaded PDF blob URL. Thanks Typescript for catching that. And then also the cover URL should be coming from here. and it is a string. Oh, but in the database we specified it as cover URL uppercased URL. So, we just have to make this little fix right here. And finally, for the persona, it's complaining right now because currently it saved it as voice. When we created this form using Juny before, and it might make a bit more sense, but you can use either voice or persona. Whatever you use, make sure to stick with it.
We'll have the title, the author, the persona as well as the PDF file at the start set to undefined as well as the cover image also at the start set to undefined. So now we have to actually choose this persona later on within one of these inputs. Cover image and then we have the title, the author, and finally a persona. And currently it's complaining about it being persona and not voice. But don't worry because we're going to fix that very soon. I think the issue is in the form control right here where it says book upload form values. And it's inferring them from the upload schema which has the book file, the cover image, the title, the author, and the voice. But instead this should be a persona. This was created by Juny. So if we fix it, you can see that it no longer complains. But it does complain about a PDF file which also needed to be a part of the upload schema. So I think we can even simplify this upload schema.
Yep, we'll simplify it a lot. First, we'll have the title which is going to be set to Z.string min character. Title is required. The author is going to be also required. The persona is going to be required. And then we have a PDF file which we can do some further zod validation for to make sure that this really is a PDF file. And the same thing goes for the cover image. So cover image right here. We could write this manually or again you can ask Juny by opening it. It'll get access to the contents and you can ask it to create helpers for validating PDF file and cover image and press enter. And it'll come up with a list of rules to ensure that what the user uploaded actually is a PDF file or a cover image. Let's see how quickly it can do it. Oh, there we go. It's already done. There we go. So, PDF file is an instance of file. PDF is required. File size less than 50. Only PDF accepted. Cover image less than 10 only JPEG and PGs and WEBPS. This is exactly what we wanted to achieve. And now when you get back, it'll understand that we need to have these fields and we have no more errors. Wonderful.
So, finally we are creating a book right here. And then once we create it, we want to check whether we have some errors by checking if there is no book. Then we want to throw a new error on the front side as well saying fail to create a book. We also want to check if a book already exists. So if book already exists, in that case we want to display some kind of a toast book already exists. But it's going to be as before an info. And I think we've done this already before, but I forgot where. Let me see. It was when we used the router. There we go. Here where we were checking for the existing check. I'll just copy this if statement that was a bit above. And paste it right here. So if book already exists, then we want to display book with the same title already exists. Reset the form. push it over to that specific book and return.
But if it doesn't and we actually have created a book, then we actually want to save the segments of the contents of the PDF to the book by saying con segments is equal to await save book segments. And we want to first provide a book ID. So that's book data ID. then the user ID and finally the segments of the book which we now have access to under parsed PDF.content. Finally, if no segment success, throw a new error. And we could even maybe display a little toast. So right here, I'll say toast. Fail to save book segments. And it seems that it's saying that segments could sometimes be undefined, which shouldn't be the case. segments should always return either a success of true or false. So it looks like here I forgot to return success of false. So it knows that it can be false as well. And finally once we are done with adding the segments to the book and we have added the book we can then reset the form and finally push to either the newly created book or for now we can just push over to the homepage to show this newly created book.
This was a long lesson. We've done a lot of work. So, let's pause for a moment and then in the next lesson, we'll go through it, try to upload a PDF, likely run into a couple of issues or bugs that happen along the way, fix them, and finally, a book will be uploaded to our database alongside with parsed segments so that very soon we'll be able to talk to it and extract useful information from it. So let's do that next.
So let's test out our form. I'll upload a book. In this case, it'll be the clean code book, a handbook of agile software craftsmanship. I think one of the go-to books for general programming. But this book is absolutely huge. I mean, it has I don't know how many pages, but nowadays you definitely don't want to go through it and read it manually. So instead, we can upload it here and hopefully we'll be able to talk to it. The first page of the book is also the cover image, so we don't have to upload a separate one. The title is clean code, and the author is Robert Cecil Martin. The voices are not working yet, but we can select one just to be able to see whether the PDF synthesis of this file is going to work. So I'll open up inspect element and the console and we'll click begin synthesis which is going to start this nice looking loading. Please wait while we process your PDF and prepare your interactive literary experience and then we'll see whether we get any errors within the console.
Okay, failed to upload a book. Please try again later. At least it was catched properly from our side. The post 400 on API upload failed. not found and versel blob failed to retrieve the client token. This error is happening because our app is trying to upload a file directly from the browser on the client side to versel blob, but it can't find the necessary serverside endpoint to authorize that request. So when using the versel blob functionality in a client component, we first need to send a post request to a handle upload URL which is typically on the backend side of the code because the server side is supposed to generate a secure client token using a custom environment variable.
So if we head over to blob versel which is where we were right before and if you take a look at environment variables you'll see that you need to have one for your project. To use the environment variable locally we recommend pulling it from the Versel CLI. So, let's get this env. You can do that by heading over to Verscell, creating a new project, and importing it from GitHub. It's going to be this one we created our GitHub repo for. Later on, we'll add all sorts of other environment variables that we need for the project to work. But for now, we're just deploying it so we can extract that additional storage environment variable. Click the deploy button. And while it's deploying, you can immediately head over to that new project by finding it on your dashboard, heading over into the storage part of your application and creating a database. You can just use the blob for the fast object storage, which is exactly what we need. The store name can be bookified and we can make it public. Region, you can choose the one closest to you and click create. Then you can click connect and that'll give you the blob variable. So you can choose a prefix such as bookified readrite token. It'll give you the instructions on how to use it. And right here under env. You can copy the snippet. Head over to your env.local and paste it right here. It's going to be the bookified readrite token. Looks like I should have used the capital letters for this one right here, but it's okay.
And now that we have this readr token, we can create a new route file. So we can call it from the server side of our application. In Nex.js you can create that under app. Another folder called API. Within API you can create a directory for uploads. And then within upload you can create a new file called route.ts which is going to act as our backend route. Within here, you can create a new function which is going to be an asynchronous function, but this is actually a post backend request. So, it's going to be a request of a type request and it'll return a promise of a type next response. And then we can open it up. First things first, we want to get access to the body that we pass into this function, which we can do by awaiting the form data. And we can even give it a type so we know what it is. That's going to be as handle upload body. So specifically we are accepting which is coming from versel blob client. And instead of form data we want to get it in a JSON format. Then you can open up a try and catch block. In the catch we can just form a error message by saying const message is equal to. We can do error is an instance of error. In that case just return the message else return an unknown error occurred. And we can also get a status by saying con status is equal to message.inccludes. If it includes unauthorized in that case we can return 401. Else we can return a 500. And then we can return all of that within a next response.json that's going to be an error of a message and then the status. Keep in mind we're responding from the backend part of our application just so we can upload to versel blob storage.
Then within here let's form the JSON response which is going to be equal to the call to the handle upload function provided to us by versel blob. to it. We need to pass the body that we're trying to upload the request as well as we can do an additional on before generate token which is a function called before generating the client token for the uploads. And here we want to make some authentication checks to see whether our users have the permissions to upload what they want to upload. So this is going to be an asynchronous callback function which we can open up right here. And within it we can first authenticate the user using clerk by getting its user ID and calling await O. O is similar to use o on the client side but here we're working on the server side. So just you know you can get authentication information about your users both on client and server side using clerk. Then you want to block anonymous uploads by checking if a user ID maybe doesn't exist. And if that is the case, we can simply throw a new error unauthorized user. Else, if everything is good, we can return and prepare for the upload by saying allowed content types. And that's going to be application PDF for the actual book. But we're also going to allow the images. So that's going to be image JPEG. We can do image PNG as well. And finally the most modern format which is going to be image webp. Then we can add a random suffix to each one of these uploaded files. I'll set that to true. We can add a maximum size in bytes which is going to be 50 megabytes. So if I do max file size I believe we have already defined this within our constants. There we go 50 megabytes. And then we can also provide the token payload which is going to be just a stringified user ID. So this is before we ever decide to upload something. I'll put this into multiple lines. So I can collapse this function before we try to upload it. And then also after we try to upload it. So that's going to be right next to on before generate request. We'll say on upload complete. Here we also want to open up an asynchronous function that's going to accept and dstructure the blob and the token payload. And here we can console log file uploaded to blob and the blob URL. So we know we can access it online publicly. We're then ready to parse the user ID from the token payload by saying const payload. And if the token payload exists in that case we can just parse it by running JSON.parse token payload. Else we can return null. And we also need to extract the user ID. And that's going to be equal to payload question mark user ID. Later on we'll be able to track this upload through post hog. So I'll add a to-do for later on so we can track what our users are doing on the application. But for now, let's just go ahead and return this response by saying return next response.json. And we're going to pass in the JSON response which we created above. So this of course has to go outside of the actual function that's creating that JSON response for us. This one right here. After we upload it and everything goes right, we can then return it and make sure that the file got uploaded successfully.
So, what do you say that we give it one more shot and try to re-upload the same book? I'll reload the page, add the book right here. It's going to be clean code by Robert Cecil Martin and we can choose a voice and click begin synthesis. Let's see how far we get. Now, this is one of the thing that I guess changed with how we're developing the applications. or I guess not. Typically, when you develop it manually, you would still run through a lot of these issues, but you would be running through more errors sooner because you would be writing everything manually and then checking it. But we typically tend to write bigger parts of the application with AI because it speeds us so much. So eventually we have more errors at the end, but the whole process should still be better.
Yep, this is what I'm talking about. we again get another error from the upload form fail to retrieve the client token. This means that we got a step further but still the issue is with the versel blob token. So back within our route if you hover over the handle upload you can see that one of the things that it accepts is a token. It's an optional string specifying the readr token to use when making requests. Defaults to blob read write token but our name is a bit different. So let's just manually define it by saying token is set to process.env dot and then we have to give it a proper name. Check out how it's called within your application. For me it is bookified read write token. And now we can give it another shot. It's taking some time for it to fully analyze it. Typically this process is significantly faster. So we'll definitely have to check how to improve it right here. Usually it takes about a couple of seconds.
And finally, we got a bit of a different error. This time coming directly from create book on onsubmit form. But this one isn't as useful. Instead, the real error is now coming from the backend side. So, if you switch over from the terminal to the app, you should be able to see a MongoDB connection error, which is going to tell us exactly what's wrong. Mongu server selection could not connect to any servers. One common reason is that you're trying to access the database from an IP that isn't whitelisted. So they give you a link right here that you can click to whitelist your instance. So to do it, you can head over to your project settings, go over database and network access, IP access list, and add an IP address. You can either add a current one or you can whitelist all the addresses. For now, just the current one should be fine. It says that it's active. So, if you get back and try to recreate the book one more time by clicking begin synthesis. There we go. This actually took like what two three seconds and it redirected us back to the homepage. This is amazing. That redirect is telling me a lot because it is the last part of the upload form. If you head over to components and then upload form and scroll all the way down, you'll see when everything is set and done, the router pushes to home only if we have reached this line, which means that everything else was successful. This is huge.
Now, of course, these books right now are fully fake. So, what we could do next is remove them because now we have actually created a real book that we can then fetch and display later on. But just before we do that, let's verify that that book indeed got created by heading over to our MongoDB cluster. Head over to browse collections. And here you should be able to see a new connection under test. I think books and book segments. Check this out. There's only one book here. There we go. Clean code by Robert Cecil Martin. Persona Daniel file URL, file blob key, cover URL. It's all here. And let's see whether the cover URL got properly saved by taking the first page of the book. And it did. This is perfect. We didn't even have to manually upload it.
But what I care even more about than just the book document could be the book segments. Check this out. There's only one book we uploaded. And out of it, we generated 284 book segments. Each one of these segments has a clerk ID associated with it. The book ID. So we know which one is it connected with the word count, the segment index, and then the full segment, which you can see right here. There we go. Maybe we can go in one of the later ones and then expand it right here. It's a bit hard to see, but if I copy it and paste it maybe in the markdown right here by heading over to the readme, you should get an idea of how the segment looks like. There we go. So this is only one segment of the book which will make it easier for our AI agent to grab a specific part that could be relevant and then speak to us very quickly about what it read in a specific segment. This decision to have book segments right here forms the entire functionality of our application later on. Or in other words, it's going to make it so much easier for VPI to be quick with the responses and also at the same time be accurate. So in the next lesson, let's remove these fake dummy books and fetch the real books that we uploaded and stored within the database.
To start fetching now real books in the database, you can head over into lib actions book actions. And right below where we have create book, checkbook exists and save book segments or right above whatever you prefer, you can create a new function that's going to fetch all the books. You can do that by saying export const get all books and that's going to be equal to an asynchronous function where you can see that even webtorm knows by now we have to connect to the database first and then for catch we can just console log the error and return. In this case we can return some kind of a success set to false and the error set as the error. But right at the top after we connected the database we can fetch all the books by saying const books is equal to await book.find and we can make it lean but I'm going to sort them first specifically by created at where it's going to start from minus one which means that it's going to show the newly created books first at the top and make sure to import books from our database model. Once we fetch the books, we can actually return them by saying success true. We can do it like this. Either return books or data and set it to serialize data set to books. So in this case, I'll just return it as data. So we can fetch it like that on the front end side.
And with that in mind, we can now use this function within our homepage. So head over to root page right here where we're mapping over the sample books. We won't need that anymore, but rather now we're going to fetch the real books using this server action. So right at the top right here, you can say const book results is equal to await get all books and you just call it like this. Since we're using a weight, make sure to make this function asynchronous like this. And then out of the book results we can just extract the books themselves by saying con books is equal to if there is book result dos success. In that case we can return book results do data or if that doesn't exist just an empty array. And now we can just map over those books instead of the sample books. So we can remove this sample books import from constants. We won't be needing that anymore. And we can map over the regular books. At the start, it'll say that books is possibly undefined. So if it is, we can make it equal to an empty array. That way, this map will know that at no point books can be undefined. We'll always be able to map through it. Thank you, Typescript, for saving us there as well.
Now, with this done, this is actually a super good error to see. It means that we have successfully stored the image and that it's trying to render the image of our new book coming from the database onto the homepage. but it can't because we haven't yet added this host name to our next config. So, simply copy the host name from here. Head over into your next config and simply add a new host name as the second object with a protocol of HTTPS and host name of whatever your key right here is public blobver forcells storage.com. Once you add it and reload, you'll be able to see that now we have one card, clean code by Robert Cecil Martin. This is exactly what we wanted to get.
Now, since the book part is looking a bit more sparse, there is nothing to show here. I'm noticing that we have also a lot of empty space right here at the top. And I'm going to do an inspect element to see where that space is coming from. But I think it's pretty obvious it's coming from this wrapper that has a padding top of 28. So back into the code, this wrapper right here, and then there's a hero section that also has a wrapper and padding top. I'm just going to remove this padding top from here. And that's going to fix the issue. So now this application is looking better and better every second because now it's pulling real data that we were able to create through our add a new book form. This is amazing. That means that the next page we'll start focusing on is going to be the book conversation page where we'll hook up VPY, the star of this project, so we can add voice capabilities to our app.
Now, just before we do that, we've added tons of functionalities. So, let's go ahead and make a commit and actually open up a new PR for all the new features that we've added. I'll go into our terminal, type git status just to see where we're at. It seems like we are on the database setup branch. We can run git add dot git commit-m implement database setup. And in this case, we've done much more as well. So I'll say much more and then run get push. Since this is a new branch, we'll have to set the upstream so that we connect the local version of the project with the remote GitHub branch. And then if you head back over to your GitHub repo, you can compare and open up a pull request. You'll immediately be able to see that we changed 18 files and added tons of lines of code. Again, I'm assuming most of these are coming from some random file that we pushed, but you'll also be able to see some important changes in our homepage, as well as the whole new upload route, adding the toaster to the layout, adding the toaster component, the upload form, and then all the database models right here. And then, yeah, 600 lines coming from package lock json. But yeah, actually, most of the lines of code are coming from the actual code we wrote.
So since there is a lot of code, it's going to be super useful to get a proper review from Code Rabbit. So let's give it a minute. And here we are. This PR introduces a comprehensive file upload and book management system integrating MongoDB for persistence, PDF parsing for cell blob for file storage, clerk authentication, and toast notifications. The changes span database models, serverside actions, API routes, form validation, and UI components to enable book uploads and associated metadata. So, there's a lot of things that we've changed in this PR. So, it's super useful to check out this sequence diagram. I'm going to expand it right here and let's see exactly what is happening. Everything starts with a user that is browsing our website through a browser. They submit a book, a PDF and a cover through our upload form, which then parses the PDF file, checks the authentication, and if the user is authenticated, makes a post request to our API upload, which then calls the handle upload function, which generates a token and then we validate that token and only then we upload the PDF and the cover to versel blob storage. Then once we get the results back, we return the upload response and we bring back that file and the image alongside the title, URLs and additional stuff and create the book. Once we create a book, we save the book segments and we insert them into the book. Then we show the upload completed event, the toe success and the book process is now done. We redirect the user back to the homepage. This is exactly what we've been working on for the past hour and it's super nice to be able to visualize it like this.
This was a fairly complex VR. So, let's see whether there are any suggestions. There's one minor potential issue. Avoid returning raw error messages to the client. Yeah, I agree. You should never do this because it can contain some internal error strings and leak some details. We should either just log it or just return the error message instead. And for this one, we have three different ways to implement the fix. The first one is to just check it, copy it, and paste it within our codebase. The second one is the commitable suggestion where you can just commit it and it'll be added to the codebase instantly. And then you can also speak to your AI agent and let it apply the change for you. In this case, let's try this commitable suggestion. Update app API upload route with this code right here. I'll say commit. Go back with their codebase to the upload route and then check the part where we're returning that error and run get pull to pull the latest changes into it. And as you can see, this was added for us without us having to type it out manually.
Now, there's a critical issue within a sauner component. I'm going to leave this one to the Shhatsen team to fix, but yeah, basically is mostly just a wrong type. This is a pretty easy way to get some open source contribution points if you take this fix and open up a PR on chats and sonner so that they can quickly apply it. Then we have a major one within the upload form fix slug source when create book returns already exists. If a race occurs between checkbook exists and create book exist book can be undefined causing a crash at the book slug. So use the slug return by create book instead. Okay, this is actually a great one. To prevent the race conditions, we need to use the one coming from the book. This is happening within the upload form. So I'll head over there. And where do we use the router.push? Not here, but here. So instead of using the exist checkbook, we'll use the regular book, which we for sure have access to because we fetched it before. Okay, this one is fixed. And with that, we're ready to push the change that we added to our codebase. So run git add dot git commit-m implement code rabbit suggested fixes and run get push. That way the changes will automatically be recognized and you'll be able to commit.
But I just noticed that after pushing initially some new suggestions just appeared. It's within the route.ts ts file saying that this one is critical and that request.json outside the try block will produce an unhandled exception or malformed input. The proposed fix is to simply put it within the try block. So head over to the upload route and right here this needs to be within the try block. Okay, this is definitely a nice catch. Why not have it in here? If it produces an error, we can then display an error message right here. In this case, we're using a non-standard versil blob token. Typically, it's better just to use the regular blob readrite token, but in this case, since it works with this one, we are good to go. And that's it. We can now commit the changes once again and run get push.
Finally, as soon as the changes are recognized, we can go ahead and merge them. Wonderful. In the next lesson, let's dive a bit deeper into the UI of the talk to your book page, which for now is just a 404, but soon enough it'll look a bit more like this. And then we are ready to hook it up with Vappy to give it its full powers. So, let's do this next. Let's first develop the UI of the talk to your book page so we can then dive right into functionality. And I think you already know what I'm going to do here. We're going to well not vibe code it but develop it with our agent. Or if you want to give it a try yourself, pause the video and rebuild this UI.
So to get started, you can open up Juni and open up a new chat to spin up a new context window. Then in the video kit link down in the description, I'll provide you with a prompt that you can use to paste right here. As I said, typically I would write this in a much shorter way and just say build a talk to your book page and share it a screenshot and it most likely would be able to do it. But in this case, just so we both get the same output, I want to make sure to provide a more detailed prompt so you can follow along nicely. Alongside the prompt, you can also go to the finished design of the website. There's going to be a deployed link again in the video kit or in the GitHub and you can just go ahead and copy this entire UI or take a screenshot and then drag and drop it right here. That'll give it some additional information and context of what needs to be done. So, we're asking it to build a book details page at books slug. fetch the book from the database using the get books by slug server action which returns the data with the title, author, cover URL and a persona. And the page has two sections. The header card which displays the book cover image overlapping with the microphone icon. Then it has a title, subtitle and three small white pills indicating a status, the voice label and the timer. And finally, there is a transcript area, which for now just has to have a centered mic button and no conversation yet text. Finally, a floating back button that's going to lead us back to the previous section. And I'm telling it to use the existing classes. Once again, if I was just manually coding this, I would just tell it go ahead and build whatever is shown in this screenshot. But since we're doing it together, I want to make sure that we have more predictable output. So find this prompt in the video kit, paste it and press enter. Then I'll open up the browser on our current codebase right now. And we can take a look at the changes that it's making is first trying to understand the entire codebase. And occasionally it might ask us to create a new folder or a new page. So I'll allow it to do that. And before it implements the changes, if I go back to my application, it looks like it has some issues with connecting to MongoDB. again saying that maybe our IP address isn't whitelisted. So I'll head back over to my Atlas dashboard and sign in. And if you head over to database and network access under IP access list, it says that the current IP address hasn't been added. So just go ahead and add the current IP address. So when you add it and reload, you'll be able to see the book. By the time we've done this, it looks like Juny already finished the implementation. It allowed us to fetch the book details and added the changes to the components. So click on the clean code book and the moment of truth. Well, this looks exactly like the final UI. This is absolutely crazy. So now that we have the UI and it even has this little hover animation, we are ready to get started implementing real functionality with Vapi. There's even a back button right here which is fully functional. So let's see how does this page that it implemented looks like. If you head over under books, that's going to be books slug page.dsx. But this books folder can also be a part of the root. Either way, we have a redirect if we're not currently logged in. We fetch the book details. Once we fetch the details, we store it into a variable. And the code is incredibly clean. It actually has this back arrow link. It has a header card at the top which shows the details and then a transcript area where we can actually start the conversation with the AI.
So the next part is to infuse this with actual logic of being able to communicate with this book specifically with its segments. So let's do that in the next lesson. Let's dive into the core of our application and that is implementing voice capabilities to be able to speak with your books. We'll use Vappy to make it work. It's a platform for building voice agents. You just need to connect your LLM, pick any kind of a voice provider like 11 Labs. Define how your agent will act and how it'll speak to your customers and that's it. Vapi will handle all the real-time audio streaming, turn taking and conversations.
So, let's go ahead and set up our assistant for this application in the Vappy dashboard and connect it to our NextJS application. Click the link down in the description to be able to follow along and see exactly what I'm seeing and then just sign up. Once you're in there, you can head over to assistance on the left side and create a new assistant. You can see I was playing a bit. This is for scheduling wellness appointments at a health clinic. But we don't need this Riley right here. So, I'll go ahead and delete it. But what we need instead is our assistant that speaks to the book. And I spent some time tweaking this system prompt to ensure that we get the best experience for our application. What's amazing nowadays is that you can just click this generate button and explain how you'd like your agent to behave and speak to the customers and it'll write the best prompt for you. So we can explore this later on. For now, let's go ahead and create a new assistant together by clicking create assistant button. You can give it a name. It can be something like JSM bookified and start from a blank template as I'll teach you how to implement it from scratch. Then click create assistant JSM bookified and let's start configuring it. Here you can see in real time what's going to be the cost per minute as well as the latency depending on which model and provider you choose the latency and other options will change. And I love how it shows you what adds to this latency. First is the transcriber then the model you use then the actual voice and finally the transport or the web. In this case, we can go for an open AI model. And specifically, I'll use the GPT40 cluster model, which is great for conversations. We'll make the assistant speak first to us. So, you don't have to be like, "Hey, what do you do?" Rather, it'll start the conversation. And we will define the first message not within here, but programmatically within our code. So, it can automatically start speaking to our users about the contents of the book. And here we have that system prompt. You can start from scratch by generating one that works for our application such as something like this. I'm developing an application where users can speak to their books. You as an assistant should act as a book and speak the contents of the book back to the user. You can see that I just talked into it and it implemented it. Recently, since I've been speaking a lot to AI agents and typing things out, I use these speechtoext applications to quickly speak into an input and automatically get it. So, you can write something like this, submit the edit, and it'll automatically create a very professional system prompt for you. You're a personable and expressive AI book assistant embodying the persona of the specific book the user is interacting with. your primary role is to bring the book's contents to life, engage with the user and so on. So already this would work amazingly well. But just to make sure we have the same experience in the video kit link down in the description, you can copy and paste the prompt that I verified and tested. It's a bit longer. It checks whether it's for academic purposes or for personal reading. It asks you whether you have read it before and it asks what you want to get out of the book. actually. So use this system prompt. Then let's scroll down. Here we have the temperature and the max tokens. You can leave that as it is.
Now under the voice configuration, you can choose which voice you want to use. There are some models provided by VPY such as Elliot in this case, but you can also use all other AI voice providers such as 11 Labs for example. And the model that I tested here is Mark from Convo AI. But don't worry, the model you chose here doesn't have to be the one that you're going to end up using because we can change the models programmatically through code. And I'll use 11 flash 2.5 because this model is very fast at interacting with the users. There's also some additional configuration for stability, clarity, speed, and so on. And you can tweak this later on as you understand a bit better how your AI is responding to you. But with that in mind, let's just go ahead and publish. It'll recognize all the changes. Click next, publish, and your assistant is now published, and you can actually start speaking with it right away. But if you tried speaking with it through this interface, you'll notice that it won't have all the necessary context such as the book ID or the title of the book. These are specific variables that we're going to pass from our application to the assistant because only that way it'll have the necessary contents to make our application what it is. We'll feed all those segments we stored into our database into it and then allow it to speak intelligently with the user about the contents that we feed into it.
Now another thing that we need to do to make that work is not only create an assistant but also create a tool in this case called search book. I already created one right here, but I'll guide you through the process of creating a new one from scratch. I'll rename this one to search books just so I can create a new one with the right name. It's going to be a custom tool that's going to interact with our application. Call it search book. Then scroll down to parameters where you can add two properties. One is going to be the query of a type string and it'll be required and the other one is going to be a book ID also of a type string and required. As I said, these will give us the necessary information so that the assistant can speak intelligently about the book. Then here we'll have to point to our server URL. If you head back over to Verscell where we uploaded it, JSM Bookified, at least it is for me. Here you'll be given a domain and in this case it is jsmbookified.verell.app. For you it's going to be something different. So head over to domains and copy it. Then paste it right here. Start with an https col/ slash the name of your app.verell.app and then slap ai slvopy slash searchbook. This is going to be our backend endpoint that's going to search for the book contents and send it over to VP. I'll set the time out to about 30 seconds. And that's it. We just allowed it to communicate to our service endpoints. So go ahead and save it. And then you'll have to get back to your assistant and copy this assistant ID provided to you at the top. Then back within your application, you can head over to your env.local. We can call this one voppy. And then here you can say next_public_ass assistant ID and make it equal to the one you just copied. And then we also have to get the next public vapi API key which you can get by heading over to API keys. And you can copy the public API key right here. Perfect.
Now, if you quickly head over into the constants, you'll notice that we already have some VPI configuration right here. So, let's go through it together. As you can see, our assistant ID is already being read from this file under next public assistant ID, which is the one we just added, the one that knows how to act as a book and speak about it. And then I extracted a couple of nice voice IDs from 11 Labs that I really liked speaking with. They are very natural and engaging. I selected three male voices and two female voices. Copy their IDs and names and descriptions. If you remember, these are the ones that you're able to choose from in the add a new book form. So now we'll just hook this
up to Vappy's functionalities. Then I also gave some voice categories for the selectors, male and female. The default voice can be either one you choose. And then finally, we dive a bit deeper into some additional voice settings to make sure that the assistants reply as humanly as possible. I'll show you that first. We'll try to speak with it without adding these additional settings. And already it's going to be about 90% there, but then with a bit of tweaking, I was able to make it sound exactly how I want to.
There's also some additional configuration for natural conversation. And you can configure all of these within the Vap dashboard, but I'm just keeping them here so you know what's actually changing. First is the turn taking setting. How much time is it going to wait until it responds back to the user. Again, this is just tweaking the little details. This is to figure out when it's going to stop speaking or after how much silence should it time out. We'll go through all of these details together later on, but at the start we'll use it without it so you can see just how well it works.
So now let's put this all of this to use by creating a new folder within the root of our application called hooks. And within hooks create a new file called use vapy.ts. This is going to be our core hook where we initialize the voppy SDK. So run export const use vapy which is going to be equal to a regular function that accepts the book of a type interface book. So we can provide some information about it. Then right here we can get access to the user that's currently logged in by getting their user ID by using the use o functionality from clerk. And this is super important because later on we'll also implement the limits or the billing feature so that the users with a lower plan can only speak to one book at a time for example or that they can't add multiple books to their library. So I'll say implement limits and we'll do that very soon.
But first onto the main functionality. Well first things first you got to ask yourself what are we actually managing on this page? It's going to be the status whether it's ready or not. It's going to be which model we're speaking with. The timer that we have to keep track of so we can stop the conversation if it's above the specific user's limit. We also have to keep track of the messages that are coming in and out as well as the current message that's being spoken. So we'll need to create different states and references for all of these different fields.
So I'll create a new use state snippet. The first one will be the status set status at the start set to idle. And we can also add a typcript type of call status so that we know which different statuses exist. And we can define this one right here at the top by saying export type call status. It can be idle, it can be connecting, it can also be starting. And you can find all of these within Vapi's documentation. It can also be listening. Then there's also thinking and finally speaking. We can change between all of these different statuses.
Then for another use state, we can define the list or array of messages spoken so far within the conversation. This is going to be equal to an empty array at the start and the type will be set to the messages array coming from types. Basically each message has a role who is speaking it and then the contents of that message. Then we'll also have to keep track of the current message in real time because as we're speaking we're going to add additional words to that message. So that's going to be current message at the start set to an empty string and also current user message. So this is going to be our message that we're currently saying.
And it might be better to give you an overview of how the app truly works so you better understand why do we need all of these fields. So back within the application, I'll show you the final version where you can now open up a conversation. You'll notice that the status will change and it'll start speaking. >> Hey, good to meet you. Quick question before we dive in. Have you actually read clean code yet or are we starting fresh? >> Yes, I've read it. And see how the messages is changing in real time right here. And the status is set to thinking and also the timer is going up and up. >> It seems like something went wrong on my end. Let's get back to our conversation. >> Yeah. So, you get the idea, right? And then we have to keep track of all the messages so far. So we can display them and modify the status back to ready.
So we'll also have to keep track of the duration for which I'll create another state at the start set to zero. And then we'll also need one for the limit error. That's the error message that's going to appear in case the user has crossed the limits. So I'll say limit error and set limit error. it'll be at the start set to null but later on maybe it's going to be a specific error message so it can also be of a type string.
Now when it comes to the timers for that we'll use a ref. So I'll say timer ref is equal to use ref set to no.js timeout or null at the start set to null. We also want to do the same thing for the start timer ref. So I'll say start timer ref. We want to keep track of the session ID as well. So that's going to be const session id ref which is going to be equal to a regular ref either a string or a null. And then whether we're currently stopping ref. So that's going to be const is stopping ref and that's going to be a ref of a type boolean at the start set to false. And we want to keep all of these refs in sync with the latest value to be able to use them in the callbacks.
So for that I'll create a new little hook called use latest ref. I'll create it as a new function called use latest ref which is going to accept some kind of a value. Oh you can see uh webtorm autocompleted it for me. But basically it'll accept a value then it'll create a ref out of that value and then it'll set it to the current value and return it. Of course, we can recall this use effect whenever the value changes. This is to just keep them in sync. And now we can actually create new variables out of these refs by saying book ref is equal to use latest ref of book const max duration ref is going to be equal to use latest ref. And here we want to get it from the limits dot max session minutes times 60. So that's time 60 seconds. But we can't yet have access to this max duration because we haven't yet implemented the limits through clerk. So I will comment this one out for now. There's also the duration ref which is going to be equal to use latest ref of duration. And finally and most importantly the voice itself. So I'll say const voice is equal to book persona or we can use the default voice which is going to be Rachel.
Perfect. So now we've implemented the use states and references that we're going to use throughout the rest of the conversation. And we can implement a few more useful variables such as the is active state to know when we should display which kind of status. Is active will be turned on when the status is equal to listening thinking speaking and we can also do starting. Perfect. So that is when the is active status is true. And I'll also move this max duration below because here we're going to implement the limits functionalities later on. Adding variables such as the max duration seconds, also the remaining seconds so we know when we can display the ending of the session. And finally, show time warning so we can also display some kind of a warning when we're close to being finished. We're going to implement all of these later on once we add limits.
But for now, just to finish the functionalities right here, we'll also create three helper functions. The functions to start the conversation, which is going to be equal to an asynchronous function. We can also do a function to stop the conversation. And finally, the function to clear the errors. So I'll call it clear errors. And now that we have these three functions and all of these pieces of data that we're going to use for a conversation, we can return them all from this hook. So we can consume them within the book conversation page. So I'll say return an object where we're going to include everything. the status, the is active property, the messages, the current message, the current user message, the duration, the functions to start, stop, and clear the errors, as well as everything limit related. So that's going to be max duration in seconds, remaining seconds, and show time warning, which we can also add right here. But for now, we're going to comment them out.
So now we are returning all of these useful variables from the use voppy hook. And the only thing remaining for us to do is to export it by saying export default use vapy and then use it within the book conversation page. And now head over into app books slug page. And here below you'll be able to see the transcript area. But we can actually copy this entire transcript area and replace it with a new component that we're going to add which is going to be called VPY controls because no longer is it going to be just a single div that displays an empty state. That state is going to be one of the states of the whole VP controls thing. But let's go ahead and create that component in the components folder. Create a vapontrols.tsx. run rafce and for the time being I'll simply paste this div we had within it before and then back within the page we can import this voppy controls and make sure to pass the book as its first and only prop.
Now back within the application nothing will have changed. What this now allows us to do is to turn on the use client functionalities at the top so we can interact with the browser and use different hooks. Specifically, we want to dstructure some things from the use boppy hook we just created. And we also want to pass a book to it, which now we actually have access to by dstructuring it from the props. And this book will be of a type I book. the interface of book coming from types. So which things do we want to dstructure from VPY? Well, more or less everything we specify there. So you can just go ahead here and copy those two lines of variables that we currently have and just paste them right here to dstructure everything. And that means that we can start working on the UI.
In this case, we'll also need to bring back the VPY header card because here we're displaying the book title and some other book information which is also going to be relevant for the status. Currently, the status is hardcoded, but we need to access this information dynamically from the use VPY hook. So, go ahead and copy this entire header card that is currently above the VPY controls and just go ahead and put it within VPY controls. All of that is going to be below the link. So, make sure there aren't any issues. There we go. I think we're good. Head over into WBY controls. Turn this into a React fragment so we can have adjacent elements. And then you can paste it right here. There we go. So now we have this div which we need to end properly right here. And then we have another div starting for the transcript container. So if you check it out, it's going to look like this. That means that you have to end this div after the transcript container. And now it looks great.
So now we have to implement the transcript component which displays the messages from the assistant as well as our messages right here on the right. Since this is primarily UI, I'm going to speed up our workflow by using Juni or any kind of an AI agent of your choice. Start a new chat for a clean context. go to the final deployed application and screenshot what we need. It's going to be this part below the header card we created before, but you can also include the header in the screenshot so it knows what we're talking about. And then in the video kit link down in the description, you can find a prompt for this and then we can go over it together. The goal is to build a transcript component that displays a chat style conversation between the user and an AI assistant. It receives messages in a form of an array of a role and content and the current message which is going to be streaming the AI text in real time and the current user message which is going to stream the user text. We can also modify the icon some colors and I give it information for how all of that should look like and which classes to use. Again, if you were doing this on your own, you wouldn't have to provide any of this additional information. Just build a transcript component based on the screenshot would be enough. So go ahead and press enter and let's see how well it does it.
It immediately created a new transcript component to display conversation messages. Then it tweaked the vap controls to import it and use it within its codebase right here and passed all the necessary informations to it such as the list of all the messages, the current message and the current user message so we can stream both in in real time. Made some additional little tweaks but that's more or less it. The only new thing is really this transcript component that it added within our existing VPI controls. So head over into the transcript component here. It kept our previous is empty state that we had, but now it's mapping through all of the user messages and streaming in additional messages that it's going to display.
So what's remaining for us to do right now is to simulate a real process of how we want to start speaking with the agent. And of course, we would start by hovering over this button and clicking on it, which right now, of course, and expectedly does absolutely nothing. So, let's start engineering it from there. You can find where that button is in the code. And of course, that's going to be within not transcript, but right here at the top, you will have a button that says mic off. And that button right now is doing absolutely nothing. So let's give it a functionality. I'll say on click if the state of the call is currently active. In that case we want to call our stop function. Else we want to call the start function. You remember those two functions. We're getting them from the use voppy hook right here. Stop and start. and we haven't yet implemented their functionalities as of now, but we will soon. Let's also give this button a disabled state so the users can't just keep on clicking on it. I'll say disabled if the status is equal to connecting. That way you don't create multiple connections right at the start. And also this button will have to change states visually. It can't just always be mic off. It has to show mic on if it's not currently on.
So, uh, I'll select this button right here. So, we give some context to Juny. I'll open it up, create a new chat, and just very simply tell it to add active inactive states to the mic button. When active, show a mic icon. When inactive, show a mic off icon. And also when active and AI is speaking or thinking just to show that something is happening, we can show a white pulsating ring behind the button. That's going to be the animate ping coming from Tailwind CSS. And I believe just this alone should allow us to be able to toggle on and toggle off this button. Take a look at the changes. It should do them right away. There we go. This is it. Super simple. So now later on once we actually implement the start function and modify the activity state, the button will also change letting us know that we can modify it.
But you remember when I said that now we can follow the process of how the conversation will go. We first click the button and then something happens. Well, now we know that that something is actually the start of the conversation. So we have to implement the logic on what happens once we actually click the start button. First, we'll check for the user ID. If there is no user ID, we can just return. We can even display some kind of a limit error by saying set limit error. Please log to start a conversation. Then, if we do have a user, we'll set the limit error to null at the start. And we'll set the status to connecting. Then, we'll open up a try and catch block. In the catch, we'll simply console.log log or console. Error starting a call and set the status to idle as well as set the limit error to an error occurred while starting the call.
And now the magic happens. We have to start the actual voice session. And for that we'll have to create a new actions file within lib actions. And this one will not be about books but it'll be about active sessions. So create a new file and call it session.actions.ts ts and within it add use server at the top so we know that these server actions are happening on the server and export and create a new start voice session action which takes in the clerk ID so we know who is starting the session and then the book ID so we know which book is it starting the conversation with and this function will return a promise with a start session result which is basically going to be success session ID max duration minutes and an error if it is there.
So now open up a try and catch block. In the catch you can just console. Error starting a voice session and return a success of false and an error of actual error or you can just say failed to start a voice session. And in the try we can first try to connect to our database. You know we have to do that whenever we use a server action. And then later on we'll check the limits and the billing plan to see whether a session is allowed. But for now we can just create a new session for everybody by saying session is equal to await voice session which is a database model.create create and we're basically creating a new documented database to which we pass the clerk ID, the book ID, the started at field which is going to be equal to a new date. I'll put this into a new line and we'll also add a billing period start. So this is when we're starting and here we'll have to create a function that's going to get the first day of the current month so that we can use it for the billing period tracking. We can write it right above by saying export const get current billing period start which is going to return a date within it. We have to get access to the current date and time and then we need to return a new date. get the current full year and the current full month and then just decide to return the first day. So we're going to say one and then all zeros at the end. Since we're in an asynchronous function, you can declare it here because everything here has to be async. So I'll copy it here and I'll add it to a new file which we can create. That's going to be under lib. And within the lib, you can create a new file called subscription dash constants.ts and then put it right here. This function will return a date. Perfect. So now we can import it right from here. Get current billing period start. And we also want to provide the duration in seconds which at the start is going to be set to zero. This is used in case we want to increase the duration across multiple sessions later on. And this is it. This is our session. It requires the info about who is the user speaking to a book, which book they're speaking to, the start of that, the billing period start, and the duration in seconds.
Finally, we are ready to return a success of true, a session ID, which we need to stringify if we want to send it back to the front end. But that's going to be session. id because that's how MongoDB saves them. And then later on here we can also provide the max duration in minutes coming from the check. We'll be able to do that once we implement the billing plans. Also let's define that both the clerk ID and the book ID are strings. And while we're here, what do you say that we also implement the function for ending the voice session, which we can do right below, or you can just open up your AI agent and ask it to do that for you. We can say something like create an end voice session server action that takes a session ID and duration seconds and then updates the voice session document with ended at field and duration seconds so that we know how many seconds in that specific session the user has spent talking to a book. And it just needs to return either a success of true or uh false. And that's it. So let's see how quickly and how successfully can it do it right here. I just close the window because I know we should be able to start seeing some code pop up on the screen right about now. And there we go. We have the end voice session accepting the session ID and duration in seconds. It first tries to connect to a database and then it updates the voice session by finding it in the database first, modifies the ended date and adds on to the final duration in seconds. Finally, it returns a success of true. Or maybe if we don't have an active session, we can check that right here by saving this session into a result and then saying if there is no result, return voice session not found.
Perfect. So now we have created these two server actions which we can call back within use vapy on the start command or at least the first one will be called on the start the second one will be called on stop. So finally at the top of this try block say result or this is going to be the session is equal to await start voice session to which we need to provide the user id and the book id as well. Once we do that we can check whether we have some issues. So if there's no results success we can set the limit error to something like result. session limit reached something like that. Please try again later or please upgrade your plan. And then we can also set the status to idle and return. But if everything goes right, we can update the session ID ref to be equal to the current session ID or null in case it doesn't exist. And then we can tell the assistant what's the first message that it can send. So const first message is equal to that's going to be a template string of something like hey good to meet you. And again I'm using this whisper flow application so that whenever I have longer messages that I have to send on Slack or longer prompts to send over to my voice agent, I can just press the command key and start narrating. So, let me show you how that looks. I'll press the control key right now. Quick question before we dive in. Have you actually read? There we go. And then we can add a variable for the book title. And then after that, I'll say yet or are we starting fresh? And you can see how well it recognizes it. What a time to be alive right now. We can code using our voice. And then finally, we are ready to turn on VPY.
Because if you think about it, so far we've been just creating a voice session within our database. This voice session right here is nothing more than just a document in the database that we created that we need for our apps functionalities to work like storing sessions, keeping track of them, and so on. But we haven't yet utilized VPY. So to use it, we can head back over to the use voppy hook for just one more second. Head over above the actual use function and create another function called get vopy which is going to give us our instance of voppy. On top of it, we can try to create a new vopppy of a type instance type and it'll be a type of vap which you can import at the top by saying import vapy coming from atvappy-ai/web. And this is of course a package that we have to install. So just run mpm install atvopy-ai/web or simply press a little button right here within webstorm which is going to do it for me. Great. So now that we have this instance of vap we can check whether it's there. So if there is no vap and if there is no vapi key and this key we can declare right above by saying const vapy api key is coming from our environment variables. Let's just make sure that it is the same. If I head over to our env, it's next public voppy API key. So, it has to be written like this. So, if we don't have an instance of VPY and if we don't have the VPY key, then we'll throw a new error saying VPY API key not found, please set it in the env file under next public. But if it exists then we'll set the voppy to be equal to the new vap instance by calling new voppy and providing our API key. And finally we can return it from here. What we've done with this little function is so that we don't have to recreate it every single time or make checks whether it exists or not. We just create it once and this function always fetches it for us or creates it if we haven't created it before.
So now back within here where we have our first message, we can now await get VPY which is going to actually give us the VPY instance onto which we can call the start command which accepts as you can see here as the first parameter the assistant ID. Thankfully, we have already stored it under constants. And then we can provide some additional options such as the first message that we wanted to send and most importantly the variable values, the title, the author and the book ID. I think all of these are coming from our book. So title is going to be book.title. The author is going to be book.author author and book ID is going to be book. id. If you remember our vap dashboard under assistance within the system prompt, we're using these variables, the book ID, the author, and the title. And these are very important for our assistant to get the contents and the context of our application. Then outside of the variable values, you can also provide some additional voice options. You can give it things like the provider which you want to use. In this case, I went for 11 laps. Then you can choose which voice you want to use and that is get voice and to it you can provide the voice ID. You can also choose the model which you want to use. I like using 11 turbo 2.5. And then we can provide some other options like the stability which I stored under voice settings.stability in constants. We can also have the similarity boost which you can get from the voice settings. The style which is going to be also coming from voice settings. And finally the speaker boost option also coming from the settings. But what I want to do for now is actually not provide any of these additional settings. So I will just comment them out so you can see how the agent will behave without providing those options at first. And that's it. We should be able to start a conversation.
While we're here, we can also quickly implement the stop function, which should be pretty simple. We just need to check the is stopping ref and set it to true. So I'll say is stopping ref.curren is equal to true. And then we need to call our get voppy instance and call stop. This is it. So, with this in mind, if you head back over to your browser and start the session, it'll seem like nothing is happening at the start, but >> hey, good to meet you. Quick question before we dive in. Have you actually read clean code yet, or are we starting fresh? >> You'll be able to see that your assistant will start talking to you. >> So, what brings you to me today? Is this for something academic or more personal curiosity? And then it's going to keep asking us some questions, but it's a bit weird talking to it because we're not seeing the transcript right now. >> That's interesting. Sounds like you're reflecting more on this back and forth than diving in. What kind of connection are you hoping to make with me? >> And you can see it just keeps speaking back. And the conversation is actually very natural.
So the last part is to hook up the conversation with the messages displaying in the transcript. I think we'll be able to do that with our AI agent by opening up a new chat and I'll just speak to it. Wire the VP event listeners in use VPY hook to track the conversation messages. Listen for message events of the type TypeScript and handle different use cases. The user partial which updates the current user message state with the live transcript. the user final which is going to be a transcript type of final where we can clear the current user message and set status to thinking and add the message to the messages array and same thing for the assistant partial and assistant final messages. Then dduplicate final messages before appending and pass messages current messages and current user message to the transcript component. So both streaming and completed messages render in real time. You can see how simple that was. You can now take some time and copy and paste some parts of it so that we get a similar output. I'll press enter and let's see how it'll approach it.
See, it's even doing a web search for the Vappy AI web SDK so it can understand how Vapy will send over those messages so that we can then create transcripts based off of them. It updated the use VPY hook by adding all sorts of different events such as call start, call end, speech start, speech end, and then on different messages, it is taking a look at the transcript and then adding it to the messages array. It's going to update the code to actually use those function. There we go. It's implemented. And the components transcript and vap controls should now be able to show the transcript messages as we speak. And again, the majority of this code is right here within the use voppy hook under this use effect where it's just listening for different events such as call and start, speech, and so on. But most importantly, the message. If message type is set to transcript, then it'll try to extract the messages. If a message type is partial, that means that it's still being spoken. So, it's going to add it to the current user message. And if it's final, it's going to add it to the messages array. And the same thing will happen for the assistant. So we have RO user and RO assistant. Perfect.
Let's go ahead and test it out by heading over here, reloading, and clicking the mic button. >> Hey, good to meet you. Quick question before we dive in. Have you actually read clean code yet or are we starting fresh? And for me, you can see that it is already working well, but it did spit out that initial message before it actually read it out. For you, it might have done something else in a bit of a different way because AI agents don't always have to give the same output. So, just to make sure we're in the same page, I'll provide the final use VPY file in the video kit link down in the description so we have the same output. You can simply copy it and just paste it here. You'll notice that we have the same thing as we had before, the speech start, speech end, and so on. And then most importantly, the message tracker where we check whether it's the user that's sending it or if it's the assistant that's sending it and whether the message is partial or whether it is final.
And now when you head back and turn on the mic. >> Hey, good to meet you. Quick question before we dive in. Have you actually read clean code yet or are we starting fresh? >> You can see that the messages are appearing as the either the assistant or myself are saying them. That's perfectly fine. And also you can see that the voice changed a bit. That's because I turned on the um custom settings from 11 Labs that we're passing to Vapi showing you how it can either do its own voices or voices from any other providers. But now let's answer our Daniel right here and tell it that no, I haven't read Clean Code yet. >> So, what brings you to me today? Is this for something academic or more personal curiosity? >> Well, it's 50/50. I'm curious, but I want to learn, too. >> All right, balancing both sides. Want me to give you a taste of what I'm about? Explore some main ideas, or is there a specific topic you're curious about? And now you could go on and continue speaking with it, but you would be speaking with a full book and retrieving all that content every time is going to take some time and the responses won't be as precise. So instead, we need to utilize this tool right here, the search book tool. That's going to give us access to the query and the book ID and then it'll feed the assistant with the book segments so it can answer more precisely. And remember the model will be able to access this tool under this specific URL. So we have to create a new folder called vapy under API and a new route under the search book folder. I'll open up Juny and let's tell it to do just that. Create a post API route at API vapy search-book that handles vapy tool calls to search book content. For each call named searchbook, extract the book ID and the query from the parameters and then call searchbook segments that accepts the book ID, the query and the number of the segment to find the top three matching segments. Then combine the match segments and their contents into a single string separated by double new lines and return it as a result. If no matches are found, then just return no information found about this topic.
There we go. I think this is going to be okay. So, you can pause the video and type this out on your own or just use that whisper flow tool to narrate it to your agent. I'll press enter. So, let's see just how well it does. It'll now go ahead and create a new API route right here under API and it'll call it vapy search book and in the comments before whenever I provided some AI generated code some of you mentioned that the code is not exactly the same. So for that reason once the code gets generated I'll also share with you the final output to make sure that there are no issues and you can proceed with the video normally. And there we go. A new file was added. A route.ts right here under API vap searchbooks route.ts. As I said, I'll provide the final version as well just to make sure that we have the same final output. And now that we have this, our assistant will call this post request and get access to all the information about the book. Then once this tool extracts the query ID and most importantly the book ID, it'll then automatically call the search book segments part from the book actions that is right here within actions book actions. And I'll also provide this function right here in the video kit link down in the description just to make sure we have it the same. But basically it's searching for a specific query within this specific book. Then we're doing the MongoDB text search first which requires the text index. We're trying to find the important information and return it. But if it can't find anything, we can also try a search using regular expressions. And then we return all the segments that are relevant to the answer that the assistant needs to provide to the user. Then the responses flow back. we go back to the assistant which now has all this information and can speak knowledgeably about the book.
Now for this to work we have to deploy our application to Versell so that it can actually ping this server URL. So this is a perfect time to commit everything we developed so far push it to the internet do a quick PR check and then test it out with VPY now understanding the book segments. We can do that by running git status and you'll see that we have all of these uncommitted files. Then run git add dot getit commit-m and say implement voice assistant functionalities with voppy and then run get push. Then if you head back over to the repo you'll see that we have new changes. So you can go ahead and open up a PR. At the same time while the PR is being open, Verscell will also try to deploy this project so that our VPI instance get access to this tool that can ping our server actions now deployed and also at the same time code rabbit will be reviewing your codebase to make sure that it's scalable and that it doesn't have any bugs and we can go through them.
Starting off in the search book, we have one critical issue saying that we need to require authentication and enforce book ownership before returning content because currently we're exposing some parts of the book which is a data exposure risk. This is a great finding that I hadn't planned to tackle later on in the video. So, for now, I'll move it. Then, we have a couple of regular issues such as avoiding logging some specific parts. Again on the final codebase it makes sense to remove all the console logs here. It's complaining about use vapy dstructuring key that we don't currently have which is the clear errors which we'll add later on within our codebase under vap controls. It's mentioning that we can add an accessible name so that people with accessibility issues can click on this button for sure. The fix right here is very simple. It's just about adding a area label and a title. So, back within here, I'm going to search for mic off because that's how we're using it right here. And then on this button, we can add these two lines. The first one is area label. And then the second one is the title. So, we can start or stop the voice assistant. Then going down, we have another one where we hardcoded the timer during the duration output. Yep, I know we have to make this dynamic. So, we'll do that very soon. And then under use VPY, we need to close the server session if VPY starts fails after creation, which is a great fix. Yep, we can end the voice session if something failed. So I'll just copy this part and we're going to add it where this is within this is within use specifically in the catch. So I'll head over into the use voppy file where we have the catch part. It is right here and we need to add it right below the console log. I'll of course remove the X's. There we go. And now we have this if statement which we can indent properly. If session id rev.curren exists then we want to end the voice session in case it got started but we still ended up in catch right here. Um we can also console log it afterwards and then finally reset the session ref. Wonderful. This is great.
After that we have another major one where we need to guard the regular expression fallback when no usable keywords are extracted. So if all tokens are short like AI pattern becomes nothing and the regular expression matches essentially everything returning unrelated segments. This is a great one actually. So if there's nothing in the keyword we don't want to match everything. Rather we want to match nothing. So I'll go ahead and copy this code. And this right here is going to be in book actions. So I'll head over into book.actions. And that's going to be under the segments. There we go. Search book segments specifically under if segments is zero. There we go. Here. Then we can remove these X's and add it right here. Perfect. Another one related to the duration seconds. We're going to fix this later on. Oh, and then this critical one which says that the start session result is imported in session actions line three but the interface in types D is not exported. Let's see if this is really the case. I'll search for start session result and yep it's preferable then to actually export it from this file. So I'll export all of these different interfaces so that we can use them and import them um more seamlessly. And that is it. So we can go ahead and merge this to main. Once that is merged, you can head back over to Verscell and take a look at the production version of our application being built on the main branch. Let's see if it succeeds. And there we go. It is built. So you can head over to the project overview and click visit. As soon as you do that, you'll notice that you'll be redirected to the homepage, but there will be no books. This could be due to MongoDB's IP whitelist. So head over into database and network access under IP access list. You can see that the current IP address isn't added. So to prevent this from happening in the future, we can just add all IP addresses by adding it right here. It is 0.0 0.0.0/zero. This is going to add all the IP addresses, including our current one. This is what I recommend that you do for most of your MongoDB applications. And this will make it work on local host again. But back on the deployed side, there is still nothing. Let's go ahead and sign in first. I'll use my same Gmail account. And we are in, but we still can't see any books. This could be due to how Nex.js handles caching. So later on, we can implement dynamic cache. So, we always see the latest updates right here. But for now, let's solve it by adding a new book. I'll go with Atomic Habits by James Clear. And I think this one also has a cover image as the first page, so there's no need for us to upload it. And let's be fun and go with Chris this time. And click begin synthesis. It's going to process the PDF and prepare our interactive experience. and we get redirected back to the homepage, but we can't see any books yet. So, what we need to do is dynamically fetch new books every time we create them or every time we reload this page. But remember, we are still on the older branch and I think we forgot to push the code rabbit suggested fixes. So, this is a perfect chance for us to add these new updates such as right here on the homepage. add this export cons dynamic force dynamic line which is always going to force a new fetch of the books and then also within lib book actions right at the top you can import revalidate path from next cache which is going to revalidate the homepage so we fetch new things as soon as the new book gets created so below book.create create. You can call this revalidate path. Now with all of these changes, you can run git add dot get commit and say code rabbit suggested fixes plus cache changes and run get push. Now this will get pushed to the same branch we were on even though we already merged the PR, but that's totally fine. We can just merge it one more time. But we have to run get pull origin main first to pull the latest changes. And once you type your message, you can press the colon key and then wq and exclamation mark which is going to exit from here. Then you can run git commit changes and then get push. Now this will have pushed the latest changes to database setup branch. So when you go back to the browser, it's now going to be ahead, which means that you'll be able to open up a new pull request out of it by opening a PR from database setup to main. And since we know everything is good, but we just added little changes, we can just go ahead and automatically merge it. Then back within our application, you can check out back to the main branch and run get pull to pull the latest.
changes. Now you can just redeploy the last main build by clicking on it, going to the top right, and then clicking redeploy. This will redeploy it with the latest changes. And when the rebuild is done, you can head to the deployed application, and the books will be there.
So let's try speaking with Atomic Habits to see whether it can pull the data from the segments. Keep in mind, our only goal was for this assistant to be able to use the search book tool, which is going to ping our application's backend endpoint to be able to get the information about the book and feed it to the assistant. So, let's give it a shot. I'll turn it on.
>> Hey, good to meet you. Quick question before we dive in. Have you actually read Atomic Habits yet, or are we starting fresh?
>> Yeah, I actually read it.
>> Great. What brings you back to me today? Are you exploring something specific, reflecting on what you've learned, or just revisiting some ideas for fun?
>> Well, I'm actually recording this conversation for everybody watching JSM, and I want to share the main points from the book with them. So, what would you say are some of the biggest themes that are happening throughout the book?
The essence of Atomic Habits revolves around the idea that small, consistent changes lead to significant, transformative results over time. It emphasizes the importance of focusing on systems rather than goals, and how our identity is both shaped by and shapes our habits. Does any specific theme resonate with you more deeply, or is there a part you'd like to explore further?
>> Yeah. Yeah. I'm speaking to to the people watching right now. I mean, you saw how fast that response was. It's because it was able to pick up some important information from the book and automatically display it to us. Tell me about the analogy that the um that the author uses at the start. I think it has something to do about the bicycle races and the bikes.
>> Let me think. The analogy you're referring to is about the British cycling team's transformation by making small improvements.
>> Just how quick that was, right? It responded immediately and now, yeah, please continue. Sorry for interrupting you.
>> No problem. The analogy highlights how the British cycling team, under Dave Brailsford, focused on making tiny improvements across all aspects of their performance, which collectively led to significant gains. These marginal gains accumulated and eventually resulted in drastic improvements in their race results. How do you see this analogy applying to your own habits or goals?
The question is, how do I see them applying to um the YouTube channel that I create videos on? Like, we do these very long-form project-based builds every now and then. And um, the question is, how do we improve them? Which topics in the book can I use? Any suggestions and any parallels that you see between the book and our work at JavaScript Mastery?
Improving your long-form projects could benefit from the principles of focusing on systems rather than end goals. Much like the book suggests, you could analyze and refine each aspect of your video creation process, from planning and scripting to filming and editing, identifying small, consistent improvements. The concept of habit stacking might also be helpful, adding new positive habits to already established ones. What's one specific part of your process you'd like to optimize first?
>> Okay, I think you get the idea. The full application now is working seamlessly. It is streaming in the messages in real time as the assistant is speaking them and as I am speaking them right now, and the responses are almost instantaneous, and it can pull from the segments from the book. This is real architecture of the application and real engineering because it's not pulling from the entire book. We purposefully went through our database and pulled only the parts that we believe are necessary. Uh, that's done with the segments. But yeah, thank you so much, Chris. This is great. And um, yeah, I'll close you now.
Perfect. So now the primary functionality of our application is complete. We can actually add a new book and we can speak to that book. But there are some remaining quality-of-life changes that we still have to make, such as updating these statuses right here, changing the voice if we haven't already, and then updating the duration. And alongside those, the biggest one is going to be actually turning this application into a full-blown SaaS application where we can actually charge the users to speak with our AI agents, which we'll do in the form of subscription plans and specific features that we allow for each subscription with the help of Clerk. So let's continue finalizing our application.
Right now, our Bookified app is fully functional. Users can sign up, they can upload books, and even have an AI voice conversation with them. But there's a problem. Everyone gets unlimited access for free. So, there's no way to monetize it. So, in the next few minutes, we're going to turn this into a fully functional SaaS application with subscription plans using Clerk Billing. No custom payment code, no Stripe, no checkout pages to build, just Clerk with one prompt and a few minutes.
So before we write any code, head over into your Clerk dashboard. You'll notice that we already have our users, but now you want to switch over to billing and enable it. This is where we'll manage our plans, pricing, features, and everything else. So go ahead and enable user billing and save it. There's two ways to set this up. One is through Clerk's payment gateway, and there's also a Stripe account setup where you can either create it from scratch or use an existing one, but in development, you can test everything out right away. So head over to subscription plans and let's create the plans for our application. We'll need three separate plans: a free one, no need to create it, it's the default for users who haven't subscribed to anything. Then there's the standard and the pro. And all of them will have different features, such as the number of books and the number of sessions per month, as well as the number of minutes per session.
So go ahead and create our first plan. You can call it standard. If you want, you can give it a description and a monthly fee. Let's start with something like $9.99 per month. And we can also offer an annual discount. So, for example, it's going to be $7.99 if they decide to go with the annual plan, costing about 95 bucks. If you want, you can also enable free trials. And then you can go ahead and save it.
Now, let's go back and let's create a second plan, which is going to be called Pro. This one can be about $19.99, and with the annual discount, it can be about 15. And click save.
Now that we have the plans, we have to add the features. So you can head over under the features tab and start adding them. The first feature can be something like "up to 10 books" like this, which is going to allow the user to upload up to 10 books. And we saved it. Now we can do another one, which can be "up to 100 books." Let's do more. The next one will be "100 voice sessions per month" like this. And I'll also copy it because for the other one, we will also create a similar feature, but this one will say "unlimited voice sessions per month." Then we can have one for the duration of a session. So that's going to be about 15-minute sessions. And then I'll do another one that's going to include 60-minute sessions, much longer. We can also add something like "priority support," which we're going to add on the Pro plan.
So now, let's use these features within our plans. I'll head over into the standard plan and I'll add features. We'll do 10 books, 100 voice sessions, 15-minute sessions, and that's it. And save it. And then for the Pro plan, I'll also start adding features: up to 100 books, unlimited voice sessions, and 60-minute sessions, and priority support.
Now, I just noticed I have a typo on the unlimited sessions. So, we can quickly edit it and make it say "unlimited" and properly spell it out right here as well. Perfect.
Now, these features are purely for display in the pricing table because the actual enforcement, like limiting how many books someone can upload, how many sessions they can start, that's what our code will handle, and that's exactly what we're going to use our AI agent for.
So, this is where things get exciting. Instead of manually creating subscription utilities, plan checking hooks, and pricing pages, we're going to give our AI agent a single prompt that describes exactly what we want. So, I'll open up my preferred AI agent, which is Juny. And here, we're going to leverage Clerk's MCP server. If you haven't used MCPs before, I'm sure you've heard about them because they're incredibly useful when working with agents. Essentially, Model Context Protocol or MCP servers allow AI agents to access Clerk's SDK snippets and implementation patterns, which provides AI agents with up-to-date code examples when developing with Clerk. For example, you get SDK snippets so they can implement common Clerk features, the implementation guides including the best practices and framework-specific examples. Let me show you how you can set it up. You can follow the setup instructions here. And I can show you how you can add it within WebStorm by pressing Command Shift B and then searching for MCP. You can click on Model Context Protocol or MCP right here. Then click "Add Server" and then choose "HTTP." As soon as you do that, you can just say "Clerk" right here and then paste the address pointing to Clerk, which is https://mcp.clarker.com/mcp, and just click OK. You can apply. And now our Clerk MCP has been added.
And now we can go through this prompt together. This is a single prompt that'll hopefully allow you to add Clerk billing to any of your applications within literally minutes. You can find it in the video kit link down in the description just so we don't have to type it out by hand. But don't worry because we're going to go through it fully to understand what's happening. We're telling the AI agent to add Clerk billing to this app. Use Clerk's MCP server to look up the correct implementation patterns. Plans are already configured in the Clerk dashboard with slugs: standard and pro. Users without a subscription are on the free tier. What to build? Well, we need a shared subscription constants file with plan limits. Free: one book, five sessions per month, 5 minutes per session, no session history. Standard: 10 books, 100 sessions per month, 15 minutes per session. And we haven't implemented the session history yet. So I will delete that part from both of these. But you can see how the numbers are changing between the Pro and Standard. Implement proper server-side and client-side utilities to check the user's plan using Clerk's `has` method and develop a pricing page at `subscriptions` using the Clerk's pricing table component and style it to match the rest of the design system. Then, before doing anything important, such as uploading a new book or accessing a session or tracking billing periods by calendar month, enforce these plan checks. Add any necessary types and CSS styling to the pricing table and follow the existing codebase patterns. I think this was more than detailed enough. We could have gone through with a shorter prompt as well, but again, my goal here is that we both get very similar outputs. So let's press enter. Let's wait for our AI agent to implement this, and then we'll go through the codebase together and review all the changes, and at the end of it, we'll hopefully have a fully functional SaaS application with plans implemented in place.
It started with implementing the subscription constants to define plan types, their limits, and the current billing period starts. So if you head over to `subscriptionConstants`, you should be able to see some of these. There we go, `planLimits`. You can see that under "free" we have these. Under "standard" we have these. And under "pro" we can access more books. And I like how it used the infinity number right here for `maxSessionsPerMonth`. Perfect.
Then it's also diving into `subscriptions.server.ts` to determine the user's subscription plan. So if we head over to that file, you'll see the `getUserPlan` function, which uses the `has` method coming from Clerk's authentication system on the server, and it's checking for the plan and then returning the plan permissions: either standard, free, or pro.
Then diving further, it created a new hook called `useSubscription`. It's going to be under `hooks`, and this one is used for managing the user's subscription plans based on the authentication status and the current products that have been created. So, basically, it allows us to access the user's plan and the limits on the client side.
Then we have to enforce the actual limits, and that's happening within `bookActions`, specifically before we try creating a new book. So let's see `book.create`. Create is right here. Check this out. We first get the user plan and then we get the limits of the plan. And if the book count that this user has created is higher than the limits of `maxBooks`, we say, "You have reached the maximum number of books allowed for your plan. Please upgrade to add more books." And we're doing the similar thing for the book sessions.
Finally, we add some types, styles, add the pricing in the navigation bar, and we can call it a day. This has been great, but now we'll get to the moment of truth when we open up the landing page. I'm expecting a couple of errors to be there. Hopefully, the ones that we can fix very easily. It looks like we have a small parsing issue that's happening in the `globals` file. So, head over to `globals.css`. And it looks like it's happening at the bottom of the file. I'll comment these four out for now.
And we are back. And we now have this new pricing link right here, which is actually here and looks great. And you can notice that the style of the cards actually matches the rest of the application with these borders and the active chip at the top right. Now, we can choose to bill either monthly or annually. And if we click subscribe, check this out. We get this pop-up window on the right side allowing us to pay.
But before I do that, let me try adding a new book because currently this account has added a couple of books already. So let me try adding a new one. I'll add the book called "Deep Work." I believe it is by Cal Newport. And we can choose a voice. Then click "Begin Synthesis." And we get "Failed to upload a book. Please try again later." Let's try to reload the page and head over to the homepage to see whether the book has been added. And you can also head over to your clusters to see the number of books we have here. So this is good. The book wasn't added to the database.
Now let's figure out why this error is happening. If you head over into your `lib/actions/bookActions.ts` where we're creating our book here, we're throwing that error that we saw on the screen: "Error creating a book." Currently, we're not handling it super well because if anything goes wrong in this book creation process, and a lot of things could go wrong because we have a lot of asynchronous calls, a lot of database calls, and even these subscription checking limits. So, if anything goes wrong, it'll just throw this generic error. Most likely, our user has already created multiple books and it fails somewhere here, reaching the maximum number of books. But we don't get that error. Instead, we get this generic one. So to properly diagnose this and fix it, we should update our error handling in the upload form component to display the actual error message coming from the server. So head over to the upload form component and look for the part "if no book.success then throw this 'failed to create book' error." Change it to "throw new Error." But now let's refer to the actual book error. Or if we don't have it, then we can just use this function right here. Because when we create a book, we are expecting to return both the success and the error message.
Now that we've made these changes, you can head back to the browser and let's retry the upload. I'll say "Deep Work" by Cal and let's go with Dave and begin. I'm hoping to get a different result this time. "Failed to upload book. Please try again later." Okay, it's still coming from the same place. But check this out. The error in the console now says, "You've reached the maximum number of books allowed for your free plan. Please upgrade to add more books." But the thing is, this shouldn't really break the entire application. This should just throw a toast or an alert notifying the user that they should do that.
So now you could either fix this manually, or you can ask the AI agent to fix this for you. I'll open up Juni one more time and tell it by copying this part right here and pasting it. I'll tell it, "When I try to upload a book and I'm already over the limit, the current error handling breaks the entire application. Instead, I don't want it to throw an error, rather just display a nice toast redirecting the user to the subscriptions page so that they can upgrade their plan." And run it. Hopefully, it'll now figure this out and not make it an error and rather just make it a part of the normal application UX.
There we go. "If no book success, failed, push the subscriptions." But we only want to do this if the error is actually coming from our billing limitations from the server action. So check for the error message there and then properly send it over here so that only if it's the error related to billing, then redirect to subscriptions. Okay, so that's a little tweak, and you can notice how useful the ability to speak to your AI agent is. In this case, I'm using the WhisperFlow application. And now it's modifying the actions. It's saying "isBillingError." And then right here, it'll check only if it is a billing error. It'll then push over to subscriptions. So you are still the architect.
With that in mind, I'll now reload, re-upload the book, and again, I'm hoping to see it fail, but fail a bit more gracefully by redirecting us to subscriptions. There we go. "You've reached the maximum number of books allowed for a free plan. Please upgrade to be able to add more books." Wonderful. This is exactly what I expected.
So now we can subscribe to this plan. And we can pay with our test card. This is a super convenient way. There we go. To check whether our subscriptions are working immediately, we got redirected back to the homepage on the pricing plan. This one is now active. And if you head over to "Manage Your Account" under "Billing," you can see all the information about your payment methods, about your plans. You can switch the plans and even get statements and payments, which is typically something you would have to build manually and would take a lot of time, and you would still miss some potential edge cases. But this way, Clerk just made it super simple and super cheap to implement this.
And now we can try to add the book one more time. I'll call it "Deep Work" by Cal Newport and choose Dave as the voice. Let's see how it goes now. Hopefully, while trying to make the limit switch, we didn't break something else. Okay, looks like we still get redirected because it thinks that we're only allowed one book.
Back within the codebase, you can head over into our `subscription.server.ts` because this is where we're dealing with fetching the user plans. We also have this `getUserPlan` and `getPlanLimits`. What we can do is rename this `getUserPlanWithProducts` into just `getUserPlan` and then use it within `getPlanLimits`. That simplifies things a bit, and I think we can significantly simplify the `getUserPlan` function. Juny did complicate it here a bit. So let me show you how we can do it in a simpler way. We don't need to access these session claims as they further complicate things. They were for the second check, but again, I don't believe that's necessary, nor is the third check. I think we can get it done right from scratch. Basically, we're getting the `has` and the `userId`. We're checking if a `userId` doesn't exist, then obviously we are in the free plan. But if the user has, and in this case, it even says that "product doesn't exist," so we need to rename this to "plan" as per the official documentation. If it's "pro," we return `plans.pro`, else return `standard`, and finally, we return "free." So this is how we need to implement it. And just before we test it, since we modified the function name, you can head over into our `bookActions.ts` and right here where we're checking the limits before we add a new book, just rename this function into `getUserPlan` coming from `subscriptionServer`.
Now back into the browser. I'll once again try with "Deep Work" by Cal Newport and let's go with Dave and begin synthesis. Now, the question is, are we going to be redirected over to the billing, or are we going to get redirected back to the homepage, which is exactly what we wanted to see because as paying users, we can now upload up to 10 books. So, one of the main features of the billing has now been properly enforced. You saw that it didn't allow us to add new books unless we purchased a higher plan.
Now the next thing we need to do is track the number of voice sessions per month. But more importantly, we need to track how long does a session last. So as we start speaking with a specific book, which I'll try doing right now with "Deep Work," but when I click the microphone icon, it just turns on and off and nothing is happening. So let's try to fix it. We could either try to debug it within the application code, or we can give Juny a shot and see if it can debug it. I'll once again use that text-to-speech application that I've installed and I'll try telling it what's wrong.
"When I click the microphone icon on the book conversation page, nothing happens. I can't speak with the book, and the VAPI conversation doesn't actually start. This started happening after we implemented the limits and billing and features and plans. Before, I was able to speak to a book once I clicked the microphone icon. Please fix it. Thank you." So feel free to pause and also tell your agent something like this. And yeah, apparently if you ask them to please do something or thank them, they're going to do better work apparently.
But look, I get that this is a completely different way of developing applications, and it's a huge change from writing code fully manually, but the real workflow, at least today, is somewhere in between. You just got to learn how to use AI to your advantage because it's going to make you that much faster of a developer. I mean, personally, I haven't felt this excited about development in about 7 years. Like, now I feel like a superhuman developer that can push out a lot of code and fix it if it breaks. But still, AI codes it initially, and then I am the one that's architecting and leading the way for it to develop applications such as this one. And recently, I've been seeing a lot more of websites like these pop up where it's not just boring UIs, but like full-blown 3JS animations. Yep, that can be coded, too. That's exactly why I'm working on the Aentech Development course. The future is here, and you just got to learn how to use it to your advantage.
But let's see if I will eat my words once we check out whether Juny successfully fixed this or not. It says that it fixed the microphone icon interaction that failed to start by resolving a runtime error caused by an incorrect function call. It renamed the non-existent `getPlanWithProducts` to `getPlan` to ensure session limits are correctly checked. And just before we try speaking with a book one more time, head over into the `sessionActions`. And here, look, we're still calling `getUserPlanWithProducts`, but now it's just `getUserPlan`. Remember, we changed that. So, make sure to change it here as well. There we go.
If you get back to the application and reload.
>> Hey, good to meet you. Quick question before we dive in. Have you actually read Deep Work yet, or are we starting fresh?
>> No, I haven't.
>> No worries. We can work with that. What made you curious about Deep Work in the first place?
>> I love your voice. Um...
>> Thank you. I'm glad to hear that. So, what are you hoping to find or explore within me?
>> Give me the most important point from the book in a very short sentence.
>> Let me think. The essence is that deep, focused work is crucial for producing meaningful and high-quality results in a world full of distractions. Does that resonate with what you're looking for?
>> Yeah, yeah, yeah, it totally does. Thank you.
>> Okay, great. As you can see, everything worked, and Dave really got a unique voice. You can totally go ahead and add different ones and mix and match when you add new books. But yeah, this is looking great.
Now, another thing that we have to fix are these statuses. So, we have to get it to actually change the status as it's speaking, thinking, and so on, and then also for the timer to go through and actually end the session based on the user's plan. So I'll open up a new chat and tell Juny to on the book conversation page, make sure that the statuses are actually changing based on VAPI's functionalities like, is it thinking, is it speaking, is it listening, and so on, and then also make sure that the timer starts counting up. It needs to go up to, I think, 15 minutes for standard users, and then make sure that those minutes are connected with user plans. So if we cross 15 minutes, the user gets redirected back to the homepage. So we're trying to do two things right here: that is to fix the statuses and also fix the countdown. I'll give it access, and it came up with a plan.
Now, while it's developing this, I have a question for you. I, of course, use AI personally a lot when developing new applications, but how do you want me to use it within videos? Because obviously, you don't just want to see me talk with AI. That's not educational enough. But how would you want me to mix and match the use of AI so that you can stay up to date with how it's being used today professionally, but also still learn how to develop? If you have any ideas, drop them down into the chat.
But with that in mind, it looks like it implemented the changes. So if I go back, reload, and start the conversation, it says "Connecting." Then from "Connecting,"
>> Hi, good to meet you.
>> Move to "Starting," and then "Speaking."
>> Have you actually read Deep Work yet, or are we starting fresh?
>> And then it moves to "Listening," which is exactly what we want. And there's a pulsating green dot right here. And you can see the timer is also counting up. Currently, it's counting up to five minutes, indicating that it thinks that we are on a free plan because if I move over here, it looks like we should be over on the 15-minute plan. But thankfully, that's just a little fix. So, I'll allow it to continue with the rest of the steps. It might actually be modifying the max duration right now, but I just didn't see that it was stopped in the middle of the process. So, now that it finished, I'll just tell it.
"Right now, I am on the standard plan, but the timer on the book conversation page goes up to 5 minutes. So, most likely the app isn't recognizing that I'm the standard user, and it should be going to 15 instead. Test it out and make sure that it works with the user's current plan." Once again, you can do a prompt similar to this one and send it. And I'm sure it's going to do it properly right now. And of course, as models get better and better, there won't have to be too much back and forth; they'll be able to one-shot most of these features.
So now, if you go back to the application, you can see that it properly recognizes that we are the standard users, and it's going to start counting up to 15.
>> Hi, good to meet you.
>> Wonderful. Now, if we quickly log out, or let's switch the plan to Pro by just paying extra. There we go. It's this easy. And now if we go to another book, you can see that it says 60 minutes. Wonderful. So now we've implemented the statuses, the voices, and the timer. And we can of course speak with their book. So with that in mind, we have successfully added billing to our application. That means that you can now actually charge your users for the features that you worked so hard to implement. In this case, the ability for the users to speak with their books. And thanks to Clerk, it was super simple. So right now on their billing, you can track your revenue and you can see just how much money your app is making. As I said initially, Clerk's pricing is incredibly cheap. So most likely it'll continue to be free for you forever, and it'll only start costing you money if you make some serious cash through the SaaS you created. But hey, if it wasn't this simple, maybe you wouldn't have tested it and you wouldn't have succeeded in the first place. So, what I love about Clerk.io and Clerk Billing is just how simple they make it for you to bring your ideas to life and actually monetize them.
So, with that in mind, we've added tons of changes to our application. So, let's just go ahead and make a push. I'll first run `git status` to check which branch we're on. And I think we were on the main branch. Yep. This means that we have to stash our changes and move to a new branch. To do that, we just have to run `git stash` to stash our uncommitted changes. Then run `git checkout -b feat/clerk-billing` to create and checkout the new branch. Then `git stash pop` to pop all these latest files. And then `git add .`, `git commit -m "add clerk billing"`, and `git push -u origin feat/clerk-billing`. And just like that, our changes are going to be pushed to our application, and we can open up a new PR from `feat/clerk-billing` to the main branch. Let's give that rabbit some work. No code too small, no bug too tall.
And while the rabbit is doing its thing, we can also do another push. And that is to implement a feature which I missed so far, but that we have on our final deployed application. Looks like some of you already found the repo and started pushing some random books to it. Uh, but okay, what I was talking about is this search books feature where we can just search for a book and immediately get access. So let's implement that next. And again, since it's a very small feature, we can just ask Juny to do it for us. We can tell it something like this: "Add a search bar to the homepage that filters books by title or author. When the user types a query, search the books using a case-insensitive regular expression and then match on both the title and author. If the search query is empty, just show all the books. For that, you can use a server action for the search. Debounce isn't needed in this case. Just use URL search params so the search is sharable and works with Next.js server-side rendering. You can place the search bar above the books grid next to the 'Recent Books' heading on the right side." Okay, I think this is going to be good enough. Again, you can pause the screen to type it out yourself or to tell it to your AI. And let's see just how well it can add a search bar given that what I gave it right now wasn't a very detailed prompt. I literally just used English and my voice and told it what I want to see.
Okay, it looks like something is happening. The search bar appeared right here, but it looks like it's missing the icon, but it's still working on it. So, let's give it a few more seconds to see whether it finds the image. There we go. It fixed it, but something weird is happening right here. It says that it is still rendering it. So, I'll just ask it to debounce the input. Something like, "Add a debounce and remove search params from the dependency array." What the debounce does is essentially, it's not making a request to the server every time that we type a letter, but rather it's waiting until we type multiple letters until it actually fires a request to the server. So, right here, if we search for "Deep Work" or if we search for "Atomic" or "Clean," you can see this is working like a charm. And it actually looks amazing. So we have "Recent Books" on the left, and it just added this search right here. Perfect.
By this time, Code Rabbit was also supposed to be done with the walkthrough, and it is. This PR introduces a comprehensive subscription and plan-based limit system. It adds subscription plan definitions: free, standard, and pro with tiered limits. Enforces these limits server-side during book creation and when we initiate voice sessions. Then we're integrating Clerk's pricing table on the subscriptions page, and we're tracking the subscription state on the client side. Perfect. Do we get a diagram? We do. Okay, that's great. We haven't checked one in some time.
So the user tries to create a book or start a new session. Then we move over that request with the user context and then we send it over to Clerk, which authenticates the user and fetches the user plan. Then we check the plan, count how many books or sessions they have, and if the limit is exceeded, in that case, we just return the billing error, show a toast, and redirect to subscriptions so we can well, make some more money. But if the limit has not exceeded, then we either create a new book or a session, send a success, and proceed normally. Perfect. I love how this diagram explains it well.
"A rabbit hops through pricing tiers. Free and standard calm their fears. Pro unlocks the endless sky. Books and sessions soaring high." Okay, this is a nice end to our video. We have some suggestions for fixing `globals.css`. Then it's mentioning that in this case, we're using the client subscription state, but we can also resolve this server-side. So a little fix right here is to verify this on the server side as well. I would highly recommend that you add this to the codebase. This is a minor one, but we can guard the duration limit shutdown to execute once because this interval can actually call `getVapiStop` repeatedly until the call end fires. So, it's better to immediately reset the timer ref so that it can continue calling it.
Finally, this is a critical one. Code Rabbit suggests that we need to bind the book creation to the authenticated user, not the request payload. This is interesting because `data.clerk.clerkId` is client-controlled, and somebody can craft a request that can write or count against another user. Yep, because everything on the client side can be modified. So a hacker or a person trying to well, mess with our application could actually change this ID. So what we need to do is resolve the identity on the server side and only use that value. So we call the user ID by awaiting `O` on the server side. And that's how we validate the users. I'll actually copy this part right here. And this is happening in `book.actions.ts` right here. So I'll just remove these three lines right here and paste what I copied. We'll have to remove these plus signs right here or take a note where the minus signs are because those are the lines that we have to delete. There we go. Once all of this is indented properly, we can take a look. And this book creation is actually happening a bit below. So I will copy it and put it here. And now you can see that we're passing the `clerkId` as the `userId` to the book creation process. So that way, we're tying it to that user. Wonderful. We'll definitely get this tested once again to see to make sure that it works. But this was a phenomenal real security breach that we had within our application that Code Rabbit caught. And in this case, Code Rabbit didn't just act as a quality assurance person, which is a job on its own, but it also acted as a penetration tester where it told us how somebody could mess with our application. There's another big one right here, and it's telling us that we shouldn't trust the function argument `clerkId` for ownership because it's client-supplied. So, this is basically the same thing that we just resolved. We would have to move this over to the server side.
Okay, great. So before we merge this PR, let's just head back within the application and try to upload another book. I'll go with "JavaScript: The Definitive Guide," call it "JavaScript." And this book is by David. Let's go with the female voice this time. And click "Begin." And we got an error. And I'm actually super happy that we got this error because it allowed us to break the application on such a large book. And yep, that "JavaScript: The Definitive Guide" is an enormous book. So we crossed over the server action limit, which is one megabyte. So to fix this issue, you can head over into your `next.config.js`. Go into the `experimental` within it. Head over for the server actions and within it, you can modify the `bodySizeLimit` and you can make it something like 100 megabytes to make sure that we don't hit that limit. Once you do that, you can just re-upload and let's try one more time.
In this case, we got "Book with the same title already exists." That's because we already uploaded it before, but it wasn't able to properly add the segments. So what I'll do is head over into the MongoDB admin, remove this book from here, and then re-add it. So let's see how it goes right now. Will it be able to read and parse through this huge book so we can actually speak with it and learn JavaScript once and for all? It looks like we are successful. So that means that we can make a push with those Code Rabbit suggested fixes by saying `git add .`, `git commit -m "implement code rabbit suggested fixes"`, and running `git push`. This will push it to the same branch we're on, and I think we fixed most of the other suggestions. So let's just go ahead and merge it to main.
And with that, our application is now complete. And this latest merge, which we've just done, should start a new deployment over on main, which means that as soon as this build is ready, we're live in production with the final version of our application. So head over to overview and click "Visit." You'll be able to see all the books you've created so far. And to test it out, try speaking with the last one we've added. Who knows, maybe you learned something new.
>> Hey, good to meet you. Quick question before we dive in. Have you actually read JavaScript yet, or are we starting fresh?
>> Yeah, I I mean, I am JavaScript Mastery. Of course, I read it.
>> Nice. Sounds like you're coming at this with some confidence. So, what brings you back to me today? Are you looking to sharpen up on something specific, or just exploring deeper for the fun of it?
>> It's always fun. I love JavaScript. Always have and always will. And that's why I'm doing this on JavaScript Mastery. Jokes aside, huge congratulations on coming to the end of this phenomenal build. Both Clerk and Vapi played a huge role in making this project come to life. I hope you've learned a lot, and that you found the process of working with an AI agent, in this case Juni, within this video interesting. As it says right here, it was a pleasure helping us with the Bookified project. AI agents are becoming more and more powerful day by day. So you can expect more videos teaching you how to use them properly. With that in mind, thank you so much for watching and coming to the end of this video. If you'd like to dive deeper, specifically with Next.js and rebuild Stack Overflow, you can do that. Maybe you want to dive deeper into creating phenomenal animations with GSAP, or adding some 3D elements to your apps with Three.js. You can, of course, always go for improving your plain JavaScript knowledge or your testing skills, which are super important today. Or if you know all of these and you're ready to get a job, you can go for the Dev Career Accelerator, helping you land the role you deserve. I'll see you on jsmastery.com. I'm Adrian, and have a wonderful day.