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
- Server Components
- Client Components
- Nested Routing
- Dynamic Routes
-
Loading and Error Handling
-
Server Actions
- Creating and using server actions
- Integrating server actions with client components
-
Data Fetching and Caching
- Using
unstable_cache
for server-side caching 1. Revalidating cache withrevalidateTag
- Using
unstable_cache
for server-side caching
- Using
-
Streaming and Suspense
- Page-level streaming with loading.tsx
- Component-level streaming using Suspense
-
Parallel Routes
- Creating and using named slots
- Implementing simultaneous rendering of multiple pages components
-
Error Handling
- Implementing error boundaries with error.tsx
Our final notes taking application code will look like this:
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!
Key Concepts
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.
App Router
The App Router is a new directory "/app" which supports many things which were not possible in the legacy "/page" directory such as:
-
Server Components.
-
Shared layouts: layout.tsx file.
-
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.
Server Components vs Client Components
Let's dive into a really important topic which everyone should master before even touching Nextjs /app Router.
Server Components
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.
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.
Client Components
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
Different Composability Permutations.
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.
Rendering a Server Component as a Child of a Client 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.
Rendering a Server Component as a Child of a Client Component
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.
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.
A Workaround Rendering Server Components Inside Client Components
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.
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 Action
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.
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
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
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.
Business Logic Setup
Creating our Note model
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.
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.
Creating a simple in-memory database
We will be storing and manipulating our notes in memory.
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...).
Creating our application use cases
Create Note Use Case
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.
Update Note Use Case
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.
Delete Note Use Case
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.
Get Note Use Case
The getNote function is self-explanatory, it will simply find a note given its id.
Get Notes Use Case
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.
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.
Get Notes Summary Use Case
Get Recent Activity Use Case
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.
Get Recent Tags Use Case
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.
Get User Info Use Case
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.
Get Random Note Use Case
App Router Server Action and Caching
Home Page ( Server Component Inside Client Component Workaround demo)
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
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
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
The NoteOfTheDay Client Component on the other side works as described below:
- It takes the children prop as an input (which will be our RandomNote server component in our case), and then renders it conditionally depending on the isVisible boolean state variable value.
- The component also renders a button with an onClick event listener attached to it, to toggle the visibility state value.
Notes Page
/app/notes/page.tsx
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
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
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
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
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).
Create Note
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
/app/notes/create/components/CreateNoteForm.tsx
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
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).
Note Details 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
-
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
-
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
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
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
The deleteNoteAction code works as follows:
- It's using zod to parse and validate the action inputs.
- After making sure that our input is safe, we pass it to our deleteNote use case function.
- When the action gets executed successfully, we use revalidateTag to invalidate both the "notes" and
note-details/\${where.id}
cache entries.
Edit Note Page
/app/notes/[noteId]/edit/page.tsx
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
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
The updateNoteAction action works as follows:
- The action inputs get validated using their corresponding zod schemas (WhereNoteSchema and InsertNoteSchema).
- After that the updateNote use case function gets called with the parsed and validated data.
- After updating the note successfully, we revalidate the "notes" and
note-details/\${where.id}
tags.
Dashboard Page (Component Level Streaming Feature)
/app/dashboard/page.tsx
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:
- NotesSummary takes 2 seconds to load.
- RecentActivity takes 1 seconds to load.
- TagCloud takes 3 seconds to load.
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
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
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
The NotesSummary server component renders the summary information after fetching it using the getNoteSummary use case function.
Profile Page (Parallel Routes Features)
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:
- /app/profile/@info for the user informations page.
- /app/profile/@notes for the user notes page.
Now let's create a layout file /app/profile/layout.tsx file which will define the layout of our /profile page.
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.
@info Page
/app/profile/@info/page.tsx
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
@notes Page
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
/app/profile/@notes/loading.tsx
Error Page
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
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
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
Conclusion
In this guide, we've explored key Next.js features by building a practical notes application. We've covered:
- App Router with Server and Client Components
- Loading and Error Handling
- Server Actions
- Data Fetching and Caching
- Streaming and Suspense
- Parallel Routes
- Error Boundaries
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.
Next Steps
- Explore the complete code: github.com/spithacode/next-js-features-notes-app
- Extend the application with your own features
- Stay updated with the official Next.js documentation
If you have any questions or want to discuss something further feel free to Contact me here.
Happy coding!