Next.js 15 Deep Dive: Building a Notes App with Advanced Features
Sidali Assoul
Posted on November 5, 2024
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!
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.
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>
)
}
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
"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 ...............*/}
</>
)
}
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.
import { ClientComponent } from '@/components'
// Allowed :)
export const ServerComponent = ()=>{
return (
<>
<ClientComponent/>
</>
)
}
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.
"use client"
import { ServerComponent } from '@/components'
// Not allowed :(
export const ClientComponent = ()=>{
return (
<>
<ServerComponent/>
</>
)
}
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.
import {ClientComponent} from '@/components/...'
import {ServerComponent} from '@/components/...'
export const ParentServerComponent = ()=>{
return (
<>
<ClientComponent>
<ServerComponent/>
</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 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.
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 "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
"use server"
import fs from "fs/promises"
export async function createLogAction({
name,
message,
}: {
name: string
message: string
}) {
try {
const filePath = `./logs/\${name}.log`
await fs.mkdir("./logs", { recursive: true })
await fs.appendFile(
filePath,
`\${new Date().toISOString()} - \${message}\n`
)
console.log(`Log entry added to \${filePath}`)
} catch (error) {
console.error("Failed to create log:", error)
}
}
//........ other functions
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 createLogAction from "@/server/actions.ts"
export const CreateLogButton = ({name,message}:{name:string,message:string})=>{
const [isSubmitting,setIsSubmitting]= useState(false)
const handleClick = async ()=>{
try{
setIsSubmitting(true)
await createLogAction({name,message})
}catch(err){
console.error(err)
}finally{
setIsSubmitting(false)
}
}
return (
<button onClick={handleClick} >{isSubmitting? "Loading...":"Log Button"}</button>
)
}
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.
import { z } from "zod"
export const SelectNoteSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
createdAt: z.date(),
})
export const WhereNoteSchema = SelectNoteSchema.pick({
id: true,
})
export const InsertNoteSchema = SelectNoteSchema.omit({
id: true,
createdAt: true,
})
export const QueryParamsSchema = z.object({
page: z.number(),
limit: z.number(),
search: z.string().optional(),
})
export const UpdateNoteSchema = InsertNoteSchema.partial()
export type Note = z.infer<typeof SelectNoteSchema>
export type NoteWhere = z.infer<typeof WhereNoteSchema>
export type NoteInsert = z.infer<typeof InsertNoteSchema>
export type NoteUpdate = z.infer<typeof UpdateNoteSchema>
export type QueryParams = z.infer<typeof QueryParamsSchema>
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.
import { Note } from "./core/entities/note"
//@ts-expect-error error
export const notes: Note[] = globalThis?.notes ?? [
{
id: "1",
title: "Welcome to Note Taking",
content:
"This is your first note. Start organizing your thoughts! #welcome #gettingstarted",
createdAt: new Date("2023-01-01T12:00:00Z"),
},
//...
]
globalThis.notes = notes
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.
import { NoteInsert } from "@/core/entities/note"
import { notes } from "@/db"
import { nanoid } from "nanoid"
export async function createNote(data: NoteInsert) {
const id = nanoid(8)
notes.unshift({ ...data, id, createdAt: new Date() })
}
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.
import { NoteUpdate, NoteWhere } from "@/core/entities/note"
import { notes } from "@/db"
export async function updateNote({ id }: NoteWhere, data: NoteUpdate) {
const noteIndex = notes.findIndex((n) => n.id === id)
if (noteIndex < 0) {
throw new Error("Note not found")
}
notes[noteIndex] = { ...notes[noteIndex], ...data }
}
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.
import { NoteWhere } from "@/core/entities/note"
import { notes } from "@/db"
export async function deleteNote({ id }: NoteWhere) {
const noteIndex = notes.findIndex((n) => n.id === id)
if (noteIndex < 0) {
throw new Error("Note not found")
}
notes.splice(noteIndex, 1)
}
Get Note Use Case
The getNote function is self-explanatory, it will simply find a note given its id.
import { NoteWhere } from "@/core/entities/note"
import { notes } from "@/db"
export async function getNote({ id }: NoteWhere) {
return notes.find((note) => note.id === 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.
import { Note, type QueryParams } from "@/core/entities/note"
import { notes } from "@/db"
export async function getNotes({
page = 1,
limit = 10,
search,
}: QueryParams): Promise<{ notes: Note[]; total: number }> {
const start = (page - 1) * limit
const end = start + limit
return {
notes: notes
.filter(
(n) =>
!search ||
n.title.toLowerCase().includes(search.toLowerCase()) ||
n.content.toLowerCase().includes(search.toLowerCase())
)
.slice(start, end),
total: notes.length,
}
}
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
import { notes } from "@/db"
import { sleep } from "@/lib/utils"
interface NotesSummary {
totalNotes: number
notesThisWeek: number
notesThisMonth: number
}
export async function getNotesSummary(): Promise<NotesSummary> {
const now = new Date()
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const notesThisWeek = notes.filter(
(note) => note.createdAt >= oneWeekAgo
).length
const notesThisMonth = notes.filter(
(note) => note.createdAt >= oneMonthAgo
).length
await sleep()
return {
totalNotes: notes.length,
notesThisWeek,
notesThisMonth,
}
}
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.
import { notes } from "@/db"
import { sleep } from "@/lib/utils"
interface Activity {
action: string
date: string
}
export async function getRecentActivity(): Promise<Activity[]> {
const sortedNotes = [...notes].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
const recentNotes = sortedNotes.slice(0, 5)
await sleep()
return recentNotes.map((note) => ({
action: `Created note: \${note.title}`,
date: note.createdAt.toLocaleDateString(),
}))
}
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.
import { notes } from "@/db"
import { sleep } from "@/lib/utils"
interface Tag {
name: string
count: number
}
export async function getRecentTags(): Promise<Tag[]> {
const tagCounts: { [key: string]: number } = {}
notes.forEach((note) => {
const words = note.content.toLowerCase().split(/\s+/)
words.forEach((word) => {
if (word.startsWith("#")) {
const tag = word.slice(1)
tagCounts[tag] = (tagCounts[tag] || 0) + 1
}
})
})
await sleep()
return Object.entries(tagCounts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
}
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.
import { sleep } from "@/lib/utils"
export async function getUserInfo() {
await sleep()
return {
name: "John Doe",
email: "john@example.com",
joinDate: "January 1, 2023",
notesCount: 42,
}
}
Get Random Note Use Case
import { notes } from "@/db"
import { sleep } from "@/lib/utils"
export async function getRandomNote() {
await sleep()
const randomIndex = Math.floor(Math.random() * notes.length)
return notes[randomIndex]
}
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
import NoteOfTheDay from "./components/NoteOfTheDay";
import RandomNote from "./components/RandomNote";
export default function Home() {
return (
<div className="container mx-auto py-8">
<h1 className="text-4xl font-bold mb-8">Welcome to Notes App</h1>
<NoteOfTheDay>
<RandomNote />
</NoteOfTheDay>
</div>
);
}
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
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { getRandomNote } from "@/core/use-cases/get-random-note.use-case";
export default async function RandomNote() {
const note = await getRandomNote();
return (
<Card>
<CardHeader>
<CardTitle>{note.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">{note.content.substring(0, 150)}...</p>
<p className="text-sm text-gray-400 mt-2">
Created: {new Date(note.createdAt).toLocaleDateString()}
</p>
</CardContent>
</Card>
);
}
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
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export default function NoteOfTheDay({
children,
}: {
children: React.ReactNode;
}) {
const [isVisible, setIsVisible] = useState(false);
return (
<div className="my-8">
<h2 className="text-2xl font-bold mb-4">Note of the Day</h2>
<Button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? "Hide Note" : "Show Note"}
</Button>
{isVisible && <div className="mt-4">{children}</div>}
</div>
);
}
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
import { getNotes } from "@/core/use-cases/get-notes.use-case";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import NotesList from "./components/NotesList";
import { QueryParams } from "@/core/entities/note";
import { unstable_cache } from "next/cache";
async function fetchNotes(params: QueryParams) {
return await unstable_cache(getNotes, ["notes"], { tags: ["notes"] })({
...params,
});
}
export default async function NotesPage({
searchParams,
}: {
searchParams: { page?: string; search?: string };
}) {
const page = Number(searchParams.page) || 1;
const search = searchParams.search || "";
const { notes, total } = await fetchNotes({ page, search, limit: 5 });
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Notes</h1>
<div className="mb-4">
<Link href="/notes/create">
<Button>Create New Note</Button>
</Link>{" "}
</div>
<NotesList
notes={notes}
total={total}
currentPage={page}
search={search}
/>
</div>
);
}
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
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
export default function Loading() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Notes</h1>
<div className="mb-4">
<Button disabled>Create New Note</Button>
</div>
<div className="mb-4">
<Skeleton className="h-10 w-[200px]" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-[200px] w-full" />
))}
</div>
<div className="mt-4 flex justify-between items-center">
<Skeleton className="h-5 w-[200px]" />
<div className="space-x-2">
<Skeleton className="h-10 w-[100px] inline-block" />
<Skeleton className="h-10 w-[100px] inline-block" />
</div>
</div>
</div>
);
}
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
"use client";
import { Note } from "@/core/entities/note";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { motion, AnimatePresence } from "framer-motion";
import { useNotesSearch } from "./hooks/use-notes-search";
import NoteView from "./NoteView";
export default function NotesList({
notes,
total,
currentPage,
search: initialSearch,
}: {
notes: Note[];
total: number;
currentPage: number;
search: string;
}) {
const { search, setSearch } = useNotesSearch({ initialSearch, currentPage });
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="mb-4">
<Input
type="text"
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
</div>
<AnimatePresence>
<motion.div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" layout>
{notes.map((note) => (
<NoteView note={note} key={note.id} />
))}
</motion.div>
</AnimatePresence>
<motion.div
className="mt-4 flex justify-between items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<p className="text-sm text-muted-foreground">
Showing {notes.length} of {total} notes
</p>
<div className="space-x-2">
<Link href={`/notes?page=\${currentPage - 1}&search=\${search}`}>
<Button variant="outline" disabled={currentPage === 1}>
Previous
</Button>
</Link>
<Link href={`/notes?page=\${currentPage + 1}&search=\${search}`}>
<Button variant="outline" disabled={notes.length < 10}>
Next
</Button>
</Link>
</div>
</motion.div>
</motion.div>
);
}
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
"use client"
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { motion } from "framer-motion";
import Link from "next/link";
import React from "react";
import { type Note } from "@/core/entities/note";
const NoteView = ({ note }: { note: Note }) => {
return (
<motion.div
key={note.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
>
<Card>
<CardHeader>
<CardTitle>{note.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{note.content.substring(0, 100)}...
</p>
<motion.div
className="mt-4 flex justify-end space-x-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Link href={`/notes/\${note.id}`}>
<Button variant="outline">View</Button>
</Link>
<Link href={`/notes/\${note.id}/edit`}>
<Button variant="outline">Edit</Button>
</Link>
</motion.div>
</CardContent>
</Card>
</motion.div>
);
};
export default NoteView;
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 { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useDebounce } from "@reactuses/core"
export const useNotesSearch = ({
initialSearch,
currentPage,
}: {
initialSearch: string
currentPage: number
}) => {
const [search, setSearch] = useState(initialSearch)
const debouncedSearchValue = useDebounce(search, 300)
const router = useRouter()
useEffect(() => {
router.push(`/notes?page=\${currentPage}&search=\${debouncedSearchValue}`)
}, [debouncedSearchValue, currentPage, router])
return {
search,
setSearch,
}
}
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
import CreateNoteForm from "./components/CreateNoteForm";
export default function CreateNotePage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Create New Note</h1>
<CreateNoteForm />
</div>
);
}
/app/notes/create/components/CreateNoteForm.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { createNoteAction } from "../actions/create-note.action";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
export default function CreateNoteForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await createNoteAction({ title, content });
router.push("/notes");
} catch (error) {
console.error("Failed to create note:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Input
type="text"
placeholder="Note Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<Textarea
placeholder="Note Content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={10}
disabled={isSubmitting}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Note...
</>
) : (
"Create Note"
)}
</Button>
</motion.div>
</motion.form>
);
}
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
"use server"
import { revalidateTag } from "next/cache"
import { InsertNoteSchema, NoteInsert } from "@/core/entities/note"
import { createNote } from "@/core/use-cases/create-note.use-case"
export async function createNoteAction(rawNote: NoteInsert) {
const { data, error } = await InsertNoteSchema.safeParseAsync(rawNote)
if (error) {
throw new Error(error.message)
}
await createNote(data)
revalidateTag("notes")
}
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
import { Button } from "@/components/ui/button";
import Link from "next/link";
import DeleteNoteButton from "./components/DeleteButton";
import { fetchNote } from "./fetchers/fetch-note";
export default async function NotePage(
props: {
params: Promise<{ noteId: string }>;
}
) {
const params = await props.params;
const note = await fetchNote(params.noteId.toString());
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">{note.title}</h1>
<p className="mb-6 whitespace-pre-wrap">{note.content}</p>
<div className="flex space-x-2">
<Link href={`/notes/\${note.id}/edit`}>
<Button variant="outline">Edit</Button>
</Link>
<DeleteNoteButton id={note.id} />
<Link href="/notes">
<Button variant="outline">Back to Notes</Button>
</Link>
</div>
</div>
);
}
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
import { unstable_cache } from "next/cache"
import { notFound } from "next/navigation"
import { getNote } from "@/core/use-cases/get-note.use-case"
export const fetchNote = async (id: string) => {
const note = await unstable_cache(getNote, [`note-details/\${id}`], {
tags: ["note-details", `note-details/\${id}`],
})({ id })
if (!note) {
return notFound()
}
return note
}
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
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="container mx-auto py-8">
<Skeleton className="h-12 w-3/4 mb-6" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-2/3 mb-6" />
<div className="flex space-x-2">
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-20" />
<Skeleton className="h-10 w-32" />
</div>
</div>
);
}
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 { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { deleteNoteAction } from "../actions/delete-note.action";
export default function DeleteNoteButton({ id }: { id: string }) {
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
if (confirm("Are you sure you want to delete this note?")) {
setIsDeleting(true);
await deleteNoteAction({ id });
router.push("/notes");
}
};
return (
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
);
}
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
"use server"
import { revalidateTag } from "next/cache"
import { NoteWhere, WhereNoteSchema } from "@/core/entities/note"
import { deleteNote } from "@/core/use-cases/delete-note.use-case"
export async function deleteNoteAction(whereRaw: NoteWhere) {
const { error: whereError, data: where } =
await WhereNoteSchema.safeParseAsync(whereRaw)
if (whereError) {
throw new Error(whereError.message)
}
await deleteNote(where)
revalidateTag("notes")
revalidateTag(`note-details/\${where.id}`)
}
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
import EditNoteForm from "./components/EditNoteForm";
import { fetchNote } from "../fetchers/fetch-note";
export default async function EditNotePage({
params,
}: {
params: Promise<{ noteId: string }>;
}) {
const { noteId } = await params;
const note = await fetchNote(noteId);
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Edit Note</h1>
<EditNoteForm note={note} />
</div>
);
}
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
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { updateNoteAction } from "../../actions/edit-note.action";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
export default function EditNoteForm({
note,
}: {
note: { id: string; title: string; content: string };
}) {
const [title, setTitle] = useState(note.title);
const [content, setContent] = useState(note.content);
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await updateNoteAction({ id: note.id }, { title, content });
router.push("/notes");
} catch (error) {
console.error("Failed to update note:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Input
type="text"
placeholder="Note Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<Textarea
placeholder="Note Content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={10}
disabled={isSubmitting}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating Note...
</>
) : (
"Update Note"
)}
</Button>
</motion.div>
</motion.form>
);
}
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 server"
import { revalidateTag } from "next/cache"
import {
InsertNoteSchema,
NoteUpdate,
NoteWhere,
WhereNoteSchema,
} from "@/core/entities/note"
import { updateNote } from "@/core/use-cases/update-note.use-case"
export async function updateNoteAction(
whereRaw: NoteWhere,
noteRaw: NoteUpdate
) {
const { error: whereError, data: where } =
await WhereNoteSchema.safeParseAsync(whereRaw)
if (whereError) {
throw new Error(whereError.message)
}
const { data, error } = await InsertNoteSchema.safeParseAsync(noteRaw)
if (error) {
throw new Error(error.message)
}
await updateNote(where, data)
revalidateTag("notes")
revalidateTag(`note-details/\${where.id}`)
}
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
import { Suspense } from "react";
import NotesSummary from "./components/NotesSummary";
import RecentActivity from "./components/RecentActivity";
import TagCloud from "./components/TagCloud";
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Suspense fallback={<Skeleton className="h-[200px]" />}>
<NotesSummary />
</Suspense>
<Suspense fallback={<Skeleton className="h-[200px]" />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<Skeleton className="h-[200px]" />}>
<TagCloud />
</Suspense>
</div>
</div>
);
}
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
import { getRecentActivity } from "@/core/use-cases/get-recent-activity.use-case";
export default async function RecentActivity() {
const activities = await getRecentActivity();
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
<ul>
{activities.map((activity, index) => (
<li key={index} className="mb-2">
{activity.action} - {activity.date}
</li>
))}
</ul>
</div>
);
}
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
import { getRecentTags } from "@/core/use-cases/get-recent-tags.use-case";
export default async function TagCloud() {
const tags = await getRecentTags();
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Tag Cloud</h2>
<div className="flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-gray-200 rounded-full text-sm"
>
{tag.name} ({tag.count})
</span>
))}
</div>
</div>
);
}
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
import { getNotesSummary } from "@/core/use-cases/get-notes-summary.use-case";
export default async function NotesSummary() {
const summary = await getNotesSummary();
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Notes Summary</h2>
<p>Total Notes: {summary.totalNotes}</p>
<p>Notes Created This Week: {summary.notesThisWeek}</p>
<p>Notes Created This Month: {summary.notesThisMonth}</p>
</div>
);
}
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.
export default function ProfileLayout({
children, // references the /app/profile/page.tsx
info, // referencing the /app/profile/@info named slot.
notes, // referencing the /app/profile/@notes named slot
}: {
children: React.ReactNode;
info: React.ReactNode;
notes: React.ReactNode;
}) {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">User Profile</h1>
<div className="flex gap-6">
<div className="w-1/3">{info}</div>
<div className="w-2/3">{notes}</div>
</div>
{children}
</div>
);
}
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
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getUserInfo } from "@/core/use-cases/get-user-info.use-case";
export default async function UserInfoPage() {
const user = await getUserInfo();
return (
<Card>
<CardHeader>
<CardTitle>User Information</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center space-y-4 ">
<Avatar className="w-24 h-24">
<AvatarImage
src="https://github.com/shadcn.png"
alt="User avatar"
/>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<h2 className="text-2xl font-bold">{user.name}</h2>
<p className="text-gray-500">{user.email}</p>
<div className="text-sm text-gray-500">
<p>Joined: {user.joinDate}</p>
<p>Total Notes: {user.notesCount}</p>
</div>
</div>
</CardContent>
</Card>
);
}
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
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoadingUserInfo() {
return (
<Card>
<CardHeader>
<CardTitle>User Information</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center space-y-4">
<Skeleton className="h-24 w-24 rounded-full" />
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />
<div className="space-y-2">
<Skeleton className="h-4 w-[150px]" />
<Skeleton className="h-4 w-[150px]" />
</div>
</div>
</CardContent>
</Card>
);
}
@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
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getNotes } from "@/core/use-cases/get-notes.use-case";
import Link from "next/link";
async function getLastNotes() {
const { notes } = await getNotes({ page: 1, limit: 3, search: "" });
return notes;
}
export default async function LastNotesPage() {
const notes = await getLastNotes();
return (
<div>
<h2 className="text-2xl font-semibold mb-4">Last 5 Notes</h2>
<div className="space-y-4">
{notes.map((note) => (
<Card key={note.id}>
<CardHeader>
<CardTitle>
<Link
href={`/notes/\${note.id}`}
className="text-blue-500 hover:underline"
>
{note.title}
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
{note.content.substring(0, 100)}...
</p>
<p className="text-sm text-gray-400 mt-2">
Created: {new Date(note.createdAt).toLocaleDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
/app/profile/@notes/loading.tsx
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export default function LoadingLastNotes() {
return (
<div>
<Skeleton className="h-8 w-[200px] mb-4" />
<div className="space-y-4">
{[...Array(5)].map((_, index) => (
<Card key={index}>
<CardHeader>
<Skeleton className="h-5 w-[250px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full mt-2" />
<Skeleton className="h-4 w-1/2 mt-2" />
<Skeleton className="h-3 w-[100px] mt-4" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
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
import { sleep } from "@/lib/utils";
import React from "react";
const ErrorPage = async () => {
await sleep();
throw new Error("An error occurred :( ");
return <div>Page Content</div>;
};
export default ErrorPage;
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
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Error Page Demo</h1>
<div className="space-y-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
);
}
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
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Error Page Demo</h1>
<div className="flex flex-col items-center justify-center space-y-4">
<h2 className="text-2xl font-bold text-red-600">
Something went wrong!
</h2>
<p className="text-gray-600">{error.message}</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
</div>
);
}
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!
Posted on November 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 13, 2024