Home >Web Front-end >JS Tutorial >Next.js Deep Dive: Building a Notes App with Advanced Features
## Introduction and Objectives
In this blog article, I'd like to go through the most important Next.js features which you'll need in practical scenarios.
I created this blog article as a single reference for myself and for the interested reader. Instead of having to go through the whole nextjs documentation. I think that it will be easier to have a condensed blog article with all of the nextjs important practical features which you can visit periodically to refresh your knowledge!
We will go through the bellow features together while building a notes application in parallel.
App Router
Loading and Error Handling
Server Actions
Data Fetching and Caching
Streaming and Suspense
Parallel Routes
Error Handling
Our final notes taking application code will look like this:
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
Feel free to jump straight to the final code which you can find in this Github repository spithacode.
So without any further ado, let's get started!
Before diving into the development of our notes application I'd like to introduce some key nextjs concepts which are important to know before moving forward.
The App Router is a new directory "/app" which supports many things which were not possible in the legacy "/page" directory such as:
Nested Routing: you can nest folders one inside the other. The page path url will follow the same folder nesting. For example, the corresponding url of this nested page /app/notes/[noteId]/edit/page.tsx after supposing that the [noteId] dynamic parameter is equal to "1" is "/notes/1/edit.
/loading.tsx file which exports a component which is rendered when a page is getting streamed to the user browser.
/error.tsx file which exports a component that is rendered when a page throws some uncaught error.
Parallel Routing and a lot of features which we will be going through while building our notes application.
Let's dive into a really important topic which everyone should master before even touching Nextjs /app Router.
A server component is basically a component which is rendered on the server.
Any component which is not preceded with the "use client" directive is by default a server component including pages and layouts.
Server components can interact with any nodejs API, or any component which is meant to be used on the server.
It's possible to precede server components with the async keyword unlike client components. So you're able to call any asynchronous function and await for it before rendering the component.
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
You may be thinking why even pre-render the components on the server?
The answer can be summarized in few words SEO, performance and user experience.
When the user visits a page, the browser downloads the website assets including the html, css and javascript.
The javascript bundle (which includes your framework code) takes more time than the rest of the assets to load because of its size.
So the user will have to wait to see something on the screen.
The same thing applies for the crawlers which are responsible for indexing your website.
Many other SEO metrics such as LCP, TTFB, Bounce Rate,... will be affected.
A client component is simply a component which is shipped to the user's browser.
Client components are not just bare html and css components. They need interactivity to work so it's not really possible to render them on the server.
The interactivity is assured by either a javascript framework like react ( useState, useEffect) or browser only or DOM API's.
A client component declaration should be preceded by the "use client" directive. Which tells Nextjs to ignore the interactive part of it (useState,useEffect...) and ship it straight to the user's browser.
/client-component.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
I know, the most frustrating things in Nextjs, are those weird bugs which you can run into if you missed the rules of nesting between Server Components and Client Components.
So in the next section we will be clarifying that by showcasing the different possible nesting permutations between Server Components and Client Components.
We will Skip these two permutations because they are obviously allowed: Client Component another Client Component and Server Component inside another Server Component.
You can import Client Components and render them normally inside server component. This permutation is kind of obvious because pages and layouts are by default server components.
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
Imagine shipping a client component to the user's browser and then waiting for the server component which is located inside of it to render and fetch the data. That's not possible because the server component is already sent to the client, How can you then render it on the server?
That's why this type of permutation is not supported by Nextjs.
So always remember to avoid importing Server Components inside Client Components to render them as children.
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
Always try to reduce the javascript which is sent to the user's browser by pushing down the client components down in the jsx tree.
It's not possible to directly import and render a Server Component as a child of a Client Component but there is a workaround which makes use of react composability nature.
The trick is by passing the Server Component as a child of the Client Component at a higher level server component ( ParentServerComponent).
Let's call it the Papa Trick :D.
This trick ensures that the passed Server Component is being rendered at the server before shipping the Client Component to the user's browser.
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
We will see a concrete example at the /app/page.tsx home page of our notes application.
Where we will be rendering a server component passed as a child inside a client component. The client component can conditionally show or hide the server component rendered content depending on a boolean state variable value.
Server actions is an interesting nextjs feature which allows calling remotely and securely a function which is declared on the server from your client side components.
To declare a server action you just have to add the "use server" directive into the body of the function as shown below.
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The "use server" directive tells Nextjs that the function contains server side code which executes only on the server.
Under the hood Nextjs ships the Action Id and creates a reserved endpoint for this action.
So when you call this action in a client component Nextjs will perform a POST request to the action unique endpoint identified by the Action Id while passing the serialized arguments which you've passed when calling the action in the request body.
Let's better clarify that with this simplified example.
We saw previously, that you need to use the "use server" in the function body directive to declare a server action. But what if you needed to declare a bunch of server actions at once.
Well, you can just use the directive at the header or the beginning of a file as it's shown in the code below.
/server/actions.ts
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
Note that the server action should be always marked as async
So in the code above, we declared a server action named createLogAction.
The action is responsible for saving a log entry in a specific file on the server under the /logs directory.
The file is named based on the name action argument.
The action Appends a log entry which consists of the creation date and the message action argument.
Now, let's make use of our created action in the CreateLogButton client side component.
/components/CreateLogButton.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
The button component is declaring a local state variable named isSubmitting which is used to track whether the action is executing or not. When the action is executing the button text changes from "Log Button" to "Loading...".
The server action is called when we click on the Log Button component.
First of all, let's start by creating our Note validation schemas and types.
As models are supposed to handle data validation we'll be using a popular library for that purpose called zod.
The cool thing about zod is its descriptive easy to understand API which makes defining the model and generating the corresponding TypeScript a seamless task.
We won't be using a fancy complex model for our notes. Each note will have a unique id, a title, a content, and a creation date field.
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
We are also declaring some helpful additional schemas like the InsertNoteSchema and the WhereNoteSchema which will make our life easier when we create our reusable functions which manipulate our model later.
We will be storing and manipulating our notes in memory.
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
We are storing our notes array in the global this object to avoid losing the state of our array every time the notes constant gets imported into a file (page reload...).
The createNote use case will allow us to insert a note into the notes array. Think of the notes.unshift method as the inverse of the notes.push method as it pushes the element to the start of the array instead of the end of it.
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
We will be using the updateNote to update a specific note in the notes array given its id. It first finds the index of the elements, throws an error if it's not found and returns the corresponding note based on the found index.
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
The deleteNote use case function will be used to delete a given note given the note id.
The method works similarly, first it finds the index of the note given its id, throws an error if it's not found then returns the corresponding note indexed by the found id.
"use client" import { ServerComponent } from '@/components' // Not allowed :( export const ClientComponent = ()=>{ return ( <> <ServerComponent/> </> ) }
The getNote function is self-explanatory, it will simply find a note given its id.
import {ClientComponent} from '@/components/...' import {ServerComponent} from '@/components/...' export const ParentServerComponent = ()=>{ return ( <> <ClientComponent> <ServerComponent/> </ClientComponent> </> ) }
As we don't want to push our entire notes database to the client side, we will be only fetching a portion of the total available notes. Hence we need to implement a server-side pagination.
export const Component = ()=>{ const serverActionFunction = async(params:any)=>{ "use server" // server code lives here //... / } const handleClick = ()=>{ await serverActionFunction() } return <button onClick={handleClick}>click me</button> }
So the getNotes function will basically allow us to fetch a specific page from our server by passing the page argument.
The limit argument serves to determine the number of items which are present on a given page.
For example:
If the notes array contains 100 elements and the limit argument equals 10.
By requesting page 1 from our server only the first 10 items will be returned.
The search argument will be used to implement server-side searching. It will tell the server to only return notes which have the search String as a substring either in the title or the content attributes.
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
This use case will be used to get some fake data about the recent activities of the users.
We will be using this function in the /dashboard page.
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
This use case function will be responsible for getting statistics about the different tags used in our notes (#something).
We will be using this function in the /dashboard page.
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
We will be using this use case function to just return some fake data about some user information like the name, email...
We will be using this function in the /dashboard page.
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
"use client" import { ServerComponent } from '@/components' // Not allowed :( export const ClientComponent = ()=>{ return ( <> <ServerComponent/> </> ) }
In this home page we will be demoing the previous trick or workaround for rendering a Server Component inside a Client Component (The PaPa trick :D).
/app/page.tsx
import {ClientComponent} from '@/components/...' import {ServerComponent} from '@/components/...' export const ParentServerComponent = ()=>{ return ( <> <ClientComponent> <ServerComponent/> </ClientComponent> </> ) }
In the above code we are declaring a Parent Server Component called Home which is responsible for rendering the "/" page in our application.
We are importing a Server Component named RandomNote and a ClientComponent named NoteOfTheDay.
We are passing the RandomNote Server Component as a child to the NoteOfTheDay Client Side Component.
/app/components/RandomNote.ts
export const Component = ()=>{ const serverActionFunction = async(params:any)=>{ "use server" // server code lives here //... / } const handleClick = ()=>{ await serverActionFunction() } return <button onClick={handleClick}>click me</button> }
The RandomNote Server Component works as follows:
it fetches a random note using the getRandomNote use case function.
it renders the note details which consist of the title and portion or substring of the full note content.
/app/components/NoteOfTheDay.ts
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The NoteOfTheDay Client Component on the other side works as described below:
/app/notes/page.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
We will start by creating the /app/notes/page.tsx page which is a server component responsible for:
Getting the page search parameters which are the strings attached at the end of the URL after the ? mark: http://localhost:3000/notes?page=1&search=Something
Passing the search parameters into a locally declared function called fetchNotes.
The fetchNotes function uses our previously declared use case function getNotes to fetch the current notes page.
You can notice that we are wrapping the getNotes function with a utility function imported from "next/cache" called unstable_cache. The unstable cache function is used to cache the response from the getNotes function.
If we are sure that no notes are added to the database. It makes no sense to hit it every time the page gets reloaded. So the unstable_cache function is tagging the getNotes function result with the "notes" tag which we can use later to invalidate the "notes" cache if a note gets added or deleted.
The fetchNotes function returns two values: the notes and the total.
The resulting data (notes and total) get passed into a Client Side Component called NotesList which is responsible for rendering our notes.
When the user hits refresh. A blank page will appear to the user while our notes data is being fetched.
To resolve that issue we will make use of an awesome Nextjs feature called. Server Side Page Streaming.
We can do that by creating a loading.tsx file, next to our /app/notes/page.tsx file.
/app/notes/loading.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
While the page is getting streamed from the server the user will see a skeleton loading page, which gives the user an idea of the kind of content which is coming.
Isn't that cool :). just create a loading.tsx file and voila you're done. Your ux is thriving up to the next level.
/app/notes/components/NotesList.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The Notes List Client Side Component Receives the notes and pagination related data from its parent Server Component which is the NotesPage.
Then component handles rendering the current page of notes. Every individual note card is rendered using the NoteView component.
It also provides links to the previous and next page using the Next.js Link component which is essential to pre-fetch the next and the previous page data to allow us to have a seamless and fast client-side navigation.
To handle Server Side search we are using a custom hook called useNotesSearch which basically handles triggering a notes refetch when a user types a specific query in the search Input.
/app/notes/components/NoteView.ts
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
The NoteView component is straightforward it's only responsible for rendering every individual note card with its corresponding: title, a portion of the content and action links for viewing the note details or for editing it.
/app/notes/components/hooks/use-notes-search.ts
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
The useNotesSearch custom hook works as follows:
It stores the initialSearch prop in a local state using the useState hook.
We are using the useEffect React hook to trigger a page navigation whenever the currentPage or the debouncedSearchValue variables values change.
The new page URL is constructed while taking into consideration the current page and search values.
The setSearch function will be called every time a character changes when the user types something in the search Input. That will cause too many navigations in a short time.
To avoid that we are only triggering the navigation whenever the user stops typing in other terms we are debouncing the search value for a specific amount of time (300ms in our case).
Next, let's go through the /app/notes/create/page.tsx which is a server component wrapper around the CreateNoteForm client component.
/app/notes/create/page.tsx
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
/app/notes/create/components/CreateNoteForm.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The CreateNoteForm client component form is responsible for retrieving the data from the user then storing it in local state variables (title, content).
When the form gets submitted after clicking on the submit button the createNoteAction gets submitted with the title and content local state arguments.
The isSubmitting state boolean variable is used to track the action submission status.
If the createNoteAction gets submitted successfully without any errors, we redirect the user to the /notes page.
/app/notes/create/actions/create-note.action.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
The createNoteAction action code is straightforward, the containing file is preceded with "use server" directive indicating to Next.js that this action is callable in client components.
One point which we should emphasize about server actions is that only the action interface is shipped to the client but not the code inside the action itself.
In other terms, the code inside the action will live on the server, so we should not trust any inputs coming from the client to our server.
That's why we are using zod here to validate the rawNote action argument using our previously created schema.
After validating our inputs, we are calling the createNote use case with the validated data.
If the note is created successfully, the revalidateTag function gets called to invalidate the cache entry which is tagged as "notes" (Remember the unstable_cache function which is used in the /notes page).
The notes details page renders the title and the full content of a specific note given its unique id. In addition to that it shows some action buttons to edit or delete the note.
/app/notes/[noteId]/page.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
First we are retrieving the page params from the page props. In Next.js 13 we have to await for the params page argument because it's a promise.
After doing that we pass the params.noteId to the fetchNote locally declared function.
/app/notes/[noteId]/fetchers/fetch-note.ts
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The fetchNote function wraps our getNote use case with the unstable_cache while tagging the returned result with "note-details" and note-details/${id} Tags.
The "note-details" tag can be used to invalidate all the note details cache entries at once.
On the other hand, the note-details/${id} tag is associated only with a specific note defined by its unique id. So we can use it to invalidate the cache entry of a specific note instead of the whole set of notes.
/app/notes/[noteId]/loading.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
Reminder
The loading.tsx is a special Next.js page which is rendered while the note details page is fetching its data at the server.
Or in other terms while the fetchNote function is executing a skeleton page will be shown to the user instead of a blank screen.
This nextjs feature is called Page Streaming. It allows to send the whole static parent layout of a dynamic page while streaming it's content gradually.
This increases performance and the user experience by avoiding blocking the ui while the dynamic content of a page is being fetched on the server.
/app/notes/[noteId]/components/DeleteNoteButton.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
Now let's dive into the DeleteNoteButton client-side component.
The component is responsible for rendering a delete button and executing the deleteNoteAction then redirecting the user to the /notes page when the action gets executed successfully.
To track the action execution status we are using a local state variable isDeleting.
/app/notes/[noteId]/actions/delete-note.action.tsx
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
The deleteNoteAction code works as follows:
/app/notes/[noteId]/edit/page.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The /app/notes/[noteId]/edit/page.tsx page is a server component which gets the noteId param from the params promise.
Then it fetches the note using the fetchNote function.
After a successful fetch. It passes the note to the EditNoteForm client-side component.
/app/notes/[noteId]/edit/components/EditNoteForm.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
The EditNoteForm client-side component receives the note and renders a form which allows the user to update the note's details.
The title and content local state variables are used to store their corresponding input or textarea values.
When the form gets submitted via the Update Note button. The updateNoteAction gets called with the title and the content values as arguments.
The isSubmitting state variable is used to track the action submission status, allowing for showing a loading indicator when the action is executing.
/app/notes/[noteId]/edit/actions/edit-note.action.ts
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
The updateNoteAction action works as follows:
/app/dashboard/page.tsx
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
The /app/dashboard/page.tsx page is broken down into smaller server side components: NotesSummary, RecentActivity and TagCloud.
Each server component fetches its own data independently.
Each server component is wrapped in a React Suspense Boundary.
The role of the suspense boundary is to display a fallback component(a Skeleton in our case) When the child server component is fetching its own data.
Or in other terms the Suspense boundary allow us to defer or delay the rendering of its children until some condition is met( The data inside the children is being loaded).
So the user will be able to see the page as a combination of a bunch of skeletons. While the response for every individual component is being streamed by the server.
One key advantage of this approach is to avoid blocking the ui if one or more of the server components takes more time compared to the other.
So if we suppose that the individual fetch times for each component is distributed as follows:
When we hit refresh, the first thing which we will see is 3 skeleton loaders.
After 1 second the RecentActivity component will show up.
After 2 seconds the NotesSummary will follow then the TagCloud.
So instead of making the user wait for 3 seconds before seeing any content. We reduced that time by 2 seconds by showing the RecentActivity first.
This incremental rendering approach results in a better user experience and performance.
The code for the individual Server Components is highlighted below.
/app/dashboard/components/RecentActivity.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
The RecentActivity server component basically fetches the last activities using the getRecentActivity use case function and renders them in an unordered list.
/app/dashboard/components/TagCloud.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
The TagCloud server side component fetches then renders all the tags names which were used in the notes contents with their respective count.
/app/dashboard/components/NotesSummary.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
The NotesSummary server component renders the summary information after fetching it using the getNoteSummary use case function.
Now let's move on to the profile page where we will go through an interesting nextjs feature called Parallel Routes.
Parallel routes allow us to simultaneously or conditionally render one or more pages within the same layout.
In our example below, we will be rendering the user informations page and the user notes page within the same layout which is the /app/profile.
You can create Parallel Routes by using named slots. A named slot is declared exactly as a sub page but the @ symbol should precede the folder name unlike ordinary pages.
For example, within the /app/profile/ folder we will be creating two named slots:
Now let's create a layout file /app/profile/layout.tsx file which will define the layout of our /profile page.
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
As you can see from the code above we now got access to the info and notes params which contain the content inside the @info and @notes pages.
So the @info page will be rendered at the left and the @notes will be rendered at the right.
The content in page.tsx (referenced by children) will be rendered at the bottom of the page.
/app/profile/@info/page.tsx
export const ServerComponent = async ()=>{ const posts = await getSomeData() // call any nodejs api or server function during the component rendering // Don't even think about it. No useEffects are allowed here x_x const pasta = await getPasta() return ( <ul> { data.map(d=>( <li>{d.title}</li> )) } </ul> ) }
The UserInfoPage is a server component which will be fetching the user infos using the getUserInfo use case function.
The above fallback skeleton will be sent to the user browser when the component is fetching data and being rendered on the server (Server Side Streaming).
/app/profile/@info/loading.tsx
"use client" import React,{useEffect,useState} from "react" export const ClientComponent = ()=>{ const [value,setValue] = useState() useEffect(()=>{ alert("Component have mounted!") return ()=>{ alert("Component is unmounted") } },[]) //.......... return ( <> <button onClick={()=>alert("Hello, from browser")}></button> {/* .......... JSX Code ...............*/} </> ) }
The same thing applies for the LastNotesPage server side component. it will fetch data and render on the server while a skeleton ui is getting displayed to the user
/app/profile/@notes/page.tsx
import { ClientComponent } from '@/components' // Allowed :) export const ServerComponent = ()=>{ return ( <> <ClientComponent/> </> ) }
/app/profile/@notes/loading.tsx
"use client" import { ServerComponent } from '@/components' // Not allowed :( export const ClientComponent = ()=>{ return ( <> <ServerComponent/> </> ) }
Now let's explore a pretty nice feature in Nextjs the error.tsx page.
When you deploy your application to production, you will surely want to display a user friendly error when an uncaught error is being thrown from one of your pages.
That's where the error.tsx file comes in.
Let's first create an example page which throws an uncaught error after few seconds.
/app/error-page/page.tsx
import {ClientComponent} from '@/components/...' import {ServerComponent} from '@/components/...' export const ParentServerComponent = ()=>{ return ( <> <ClientComponent> <ServerComponent/> </ClientComponent> </> ) }
When the page is sleeping or awaiting for the sleep function to get executed. The below loading page will be shown to the user.
/app/error-page/loading.tsx
export const Component = ()=>{ const serverActionFunction = async(params:any)=>{ "use server" // server code lives here //... / } const handleClick = ()=>{ await serverActionFunction() } return <button onClick={handleClick}>click me</button> }
After few seconds the error will be thrown and take down your page :(.
To avoid that we will create the error.tsx file which exports a component which will act as an Error Boundary for the /app/error-page/page.tsx.
/app/error-page/error.tsx
- app/ - notes/ --------------------------------> Server Side Caching Features - components/ - NotesList.tsx - [noteId]/ - actions/ -------------------------> Server Actions feature - delete-note.action.ts - edit-note.action.ts - components/ - DeleteButton.tsx - page.tsx - edit/ - components/ - EditNoteForm.tsx - page.tsx - loading.tsx --------------------> Page level Streaming feature - create/ - actions/ - create-note.action.ts - components/ - CreateNoteForm.tsx - page.tsx - error-page/ - page.tsx - error.tsx --------------------------> Error Boundary as a page feature - dashboard/ ---------------------------> Component Level Streaming Feature - components/ - NoteActivity.tsx - TagCloud.tsx - NotesSummary.tsx - page.tsx - profile/ ----------------------------->[6] Parallel Routes Feature - layout.tsx - page.tsx - @info/ - page.tsx - loading.tsx - @notes/ - page.tsx - loading.tsx - core/ --------------------------> Our business logic lives here - entities/ - note.ts - use-cases/ - create-note.use-case.ts - update-note.use-case.ts - delete-note.use-case.ts - get-note.use-case.ts - get-notes.use-case.ts - get-notes-summary.use-case.ts - get-recent-activity.use-case.ts - get-recent-tags.use-case.ts
In this guide, we've explored key Next.js features by building a practical notes application. We've covered:
By applying these concepts in a real-world project, we've gained hands-on experience with Next.js's powerful capabilities. Remember, the best way to solidify your understanding is through practice.
If you have any questions or want to discuss something further feel free to Contact me here.
Happy coding!
The above is the detailed content of Next.js Deep Dive: Building a Notes App with Advanced Features. For more information, please follow other related articles on the PHP Chinese website!