Build a Bookmarks Manager with Next.js
Build a fullstack bookmarks app with Next.js 16, Prisma ORM, and SQLite.
Introduction
30 Second Summary
You probably have dozens of links scattered across browser tabs, sticky notes, and random text files. Finding that one tutorial you bookmarked last week becomes a scavenger hunt.
In this project, you will build a personal bookmarks manager web app from scratch using Next.js 16, Prisma ORM, and a local SQLite database. You will save, view, filter, and delete bookmarks through a fullstack app running entirely on your machine.
What You'll Build
A fullstack bookmarks manager that uses Server Components to fetch data, Server Actions to handle mutations, and a Client Component for interactive tag filtering.
By the end of this project, you'll have:
- A working Next.js 16 App Router project with TypeScript and Tailwind CSS connected to a local SQLite database via Prisma ORM.
- Hands-on experience with Server Actions for creating and deleting bookmarks without writing API routes.
- An interactive Client Component that filters bookmarks by tag using React state.
- Secret Mission: Add a real-time search bar that filters bookmarks by title and combines with tag filtering.
Are there any prerequisites?
Basic familiarity with React and JavaScript or TypeScript is helpful. If you are rusty, that is perfectly fine. This project walks you through every step.
Before We Start
Before We Start...
Set Up the Project
A fullstack app needs three layers: a frontend for the UI, a backend for data mutations, and a database for persistence. Next.js 16 handles the first two with its App Router, and Prisma ORM connects the third.
In this step, you will scaffold a Next.js 16 project and install Prisma with a SQLite adapter. By the end, your dev server will be running and Prisma will be ready to define a database schema.
In this step, get ready to:
- Scaffold a Next.js 16 project with TypeScript, Tailwind CSS, and the App Router.
- Install Prisma ORM and the SQLite driver adapter.
- Verify the dev server starts and the default page loads.
Scaffold a new Next.js 16 project
The Next.js CLI creates a ready-to-run project in one command. You will use the recommended defaults, which include TypeScript, ESLint, Tailwind CSS, and the App Router.
- Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your new project by running:
npx create-next-app@latest bookmarks-manager --yes
What does this command do?
The npx create-next-app@latest command downloads and runs the latest Next.js scaffolding tool. The project name bookmarks-manager becomes the folder name on your Desktop.
The --yes flag accepts the recommended defaults automatically: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack, and AGENTS.md.
- Wait for the installation to finish. You should see a success message in the terminal confirming the project was created.
- Move into the new project folder by running:
cd bookmarks-manager
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop and select the bookmarks-manager folder, then click Open.
Your VS Code Explorer sidebar should now show the project files: app/, public/, package.json, next.config.ts, and more.
Don't see the project files?
Make sure you selected the bookmarks-manager folder itself, not a parent folder. The Explorer sidebar should show app/ and package.json at the top level.
If the scaffold command failed, check that npm and node are available by running node --version in the terminal. Help me troubleshoot my Next.js scaffolding issue
Install Prisma and the SQLite adapter
Prisma ORM needs two groups of packages: the CLI tools for development, and the runtime client with a database adapter. For SQLite, Prisma 7 uses the better-sqlite3 adapter.
- Open the VS Code integrated terminal by pressing Ctrl+`.
- Install the Prisma development dependencies by running:
npm install prisma @types/node @types/better-sqlite3 --save-dev
- Install the Prisma runtime packages by running:
npm install @prisma/client @prisma/adapter-better-sqlite3 dotenv
Why two install commands?
The --save-dev packages (prisma, type definitions) are only needed during development. The runtime packages (@prisma/client, @prisma/adapter-better-sqlite3) are used when your app runs.
Prisma 7 requires a driver adapter for every database. The better-sqlite3 adapter gives Prisma a fast, zero-config connection to your local SQLite file.
- You should see output confirming packages were added. If either command shows errors, check your internet connection and try again.
- Initialize Prisma with the SQLite provider by running:
npx prisma init --datasource-provider sqlite --output ../generated/prisma/client
What does this command do?
The prisma init command creates three files in your project:
- prisma/schema.prisma defines your database structure.
- prisma.config.ts tells Prisma where to find the schema and database URL.
- .env stores the DATABASE_URL environment variable.
The --output ../generated/prisma/client flag tells Prisma where to place the generated client code when you run migrations later.
- Confirm the files were created by running:
ls prisma/
You should see schema.prisma in the output.
- Verify prisma.config.ts exists at the project root by running:
ls prisma.config.ts
You should see the filename printed without errors.
Seeing a 'command not found' error for prisma?
Make sure your terminal is inside the bookmarks-manager folder. Run pwd to check your current directory.
If npx prisma still fails, confirm the install completed by checking node_modules/.bin/prisma exists. Help me fix the prisma init command
Now open prisma.config.ts in the VS Code Explorer. If you see an import "dotenv/config"; line at the top, delete that line so the file starts directly with the import { defineConfig, env } line.
✔️ Awesome, I've got everything!
Great. Make sure you have saved any changes to prisma.config.ts.
ⓧ I'd like to double check the full code
Your prisma.config.ts should look like this:
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
Your prisma/schema.prisma should look like this:
datasource db {
provider = "sqlite"
}
generator client {
provider = "prisma-client"
output = "../generated/prisma/client"
}
Verify the dev server starts
With the project scaffolded and Prisma initialized, confirm everything works by starting the Next.js development server.
- Start the dev server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js welcome page with the Next.js logo and links to documentation.
Page not loading at localhost:3000?
Check the terminal output for errors. If you see a port conflict message, another process is using port 3000. Stop it or use the alternate URL that Next.js prints (usually http://localhost:3001).
If the terminal shows a module or dependency error, run npm install again and restart the server. Help me get the Next.js dev server running
- Confirm the prisma/ directory exists by looking in the VS Code Explorer sidebar. You should see a prisma folder containing schema.prisma.
Your project foundation is solid. Next up, you will define the Bookmark model in your database schema and bring the database to life.
Define the Database Schema and Set Up Prisma Client
Your project environment is up and running. Now it's time to define the shape of your data and give your app a way to talk to the database.
Every bookmark needs a title, a URL, an optional tag, and a timestamp. You'll describe this structure in a Prisma schema, then run a migration to turn that description into a real SQLite database table.
In this step, get ready to:
- Define the Bookmark model in the Prisma schema and run a migration.
- Create a Prisma Client utility file with a singleton pattern.
- Verify the database was created using Prisma Studio.
Define the Bookmark model and run the migration
The Prisma schema is where you define your data models. Each model maps to a table in your database.
- In VS Code, open prisma/schema.prisma from the Explorer sidebar.
- Add the Bookmark model below the existing generator client block by pasting this code at the bottom of the file:
model Bookmark {
id String @id @default(cuid())
title String
url String
tag String?
createdAt DateTime @default(now())
}
What does this model do?
Each field becomes a column in your database table. @id marks the primary key. @default(cuid()) auto-generates a unique ID for every new bookmark.
The ? after String on the tag field makes it optional. @default(now()) automatically timestamps each bookmark when it's created.
Before running the migration, you need to set the database connection URL. The prisma.config.ts file reads DATABASE_URL from a .env file at your project root.
- Open the .env file in your project root (created by prisma init in the previous step).
- Replace the existing DATABASE_URL value with the SQLite path:
DATABASE_URL="file:./prisma/dev.db"
Why this path?
The file: prefix tells Prisma this is a local file-based database. The path ./prisma/dev.db is relative to your project root, placing the database file inside the prisma/ directory alongside your schema.
Now run the migration to create the actual database table from your model.
- Run the migration command in your terminal:
npx prisma migrate dev --name init
What does this command do?
This command reads your schema, generates a SQL migration file, and applies it to create the Bookmark table in a new prisma/dev.db SQLite file. It also generates the Prisma Client so you can query the database from your code.
- Confirm the migration succeeded by checking that prisma/dev.db now exists:
ls prisma/dev.db
You should see prisma/dev.db listed in the output.
Migration failed?
Make sure your .env file is saved and contains DATABASE_URL="file:./prisma/dev.db" with the file: prefix. Without it, Prisma cannot locate the database.
Also confirm you saved prisma/schema.prisma after adding the Bookmark model. The migration reads the schema from disk.
Help me troubleshoot my Prisma migration error for a SQLite database.
✔️ Awesome, I've got everything!
Great. Make sure you've saved prisma/schema.prisma and your .env file.
ⓧ I'd like to double check the full code
datasource db {
provider = "sqlite"
}
generator client {
provider = "prisma-client"
output = "../generated/prisma/client"
}
model Bookmark {
id String @id @default(cuid())
title String
url String
tag String?
createdAt DateTime @default(now())
}
Create the Prisma Client utility file
Next.js hot-reloads your code during development. Without a safeguard, every reload would create a new database connection. The singleton pattern stores one Prisma Client instance on globalThis and reuses it across reloads.
- Create a new folder called lib at your project root (the same level as app/ and prisma/).
- Create a new file called prisma.ts inside the lib folder.
- Paste the following code into lib/prisma.ts:
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";
// Create the SQLite adapter pointing to the database file
const adapter = new PrismaBetterSqlite3({
url: "file:./prisma/dev.db",
});
// Store the PrismaClient on globalThis to reuse across hot reloads
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient({ adapter });
// In development, cache the instance so hot reloads don't create new connections
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
What does this code do?
The PrismaBetterSqlite3 adapter connects Prisma to your SQLite database file. In Prisma 7, every PrismaClient requires an explicit driver adapter.
The globalForPrisma pattern stores the client instance on the global object. During development, Next.js re-executes module code on every hot reload. Without this pattern, you'd accumulate connections and eventually hit a limit.
- Save lib/prisma.ts.
You won't see a visible change yet. The next step uses this utility to query bookmarks from the database.
Seeing a module resolution error?
Make sure the lib folder is at the project root, not inside app/. The import path "../generated/prisma/client" is relative to lib/prisma.ts and expects the generated client one level up.
If the generated/prisma/client folder does not exist, re-run npx prisma generate to regenerate the client.
Help me fix a module resolution error with my Prisma Client import.
✔️ Awesome, I've got everything!
Great. Double check that lib/prisma.ts is saved.
ⓧ I'd like to double check the full code
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";
const adapter = new PrismaBetterSqlite3({
url: "file:./prisma/dev.db",
});
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Verify the database with Prisma Studio
Prisma Studio is a visual database browser that lets you inspect your tables and data directly in the browser.
- Open Prisma Studio by running this command:
npx prisma studio
- A browser tab opens at http://localhost:5555. You should see your Bookmark table listed in the sidebar.
- Click on Bookmark to view the table. You'll see the columns: id, title, url, tag, and createdAt. The table is empty for now.
- Press Ctrl+C in the terminal to stop Prisma Studio when you're done inspecting.
Prisma Studio won't open?
Make sure you stopped the Next.js dev server first if it's occupying your terminal. Open a new terminal tab to run the Studio command.
If you see a connection error, confirm that prisma/dev.db exists. If it doesn't, run npx prisma migrate dev --name init again.
Help me get Prisma Studio working with my SQLite database.
Your database is ready with a clean Bookmark table. Next, you'll build the page that queries this table and displays your bookmarks.
Build the Bookmark Display Page
Your database is ready and waiting for bookmarks. Now it is time to build the page that actually shows them.
In this step, you will replace the default Next.js welcome page with a Server Component that pulls bookmarks straight from your SQLite database. You will also seed the database with sample data so you have something to look at.
In this step, get ready to:
- Replace the default page with a Server Component that queries and displays bookmarks.
- Style the page with Tailwind CSS for a clean card-based layout.
- Seed the database with sample bookmarks and verify they render on the page.
Update the layout for your bookmarks app
Before building the page content, you need to set the app's metadata and base styling in the layout file. The layout wraps every page in your app.
- In VS Code, open app/layout.tsx from the sidebar.
- Select all the existing code and replace it with the following:
import type { Metadata } from "next";
import "./globals.css";
// Set the page title and description for the browser tab
export const metadata: Metadata = {
title: "Bookmarks Manager",
description: "A personal bookmarks manager built with Next.js 16 and Prisma",
};
// RootLayout wraps every page in the app
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50 min-h-screen">{children}</body>
</html>
);
}
What does this code do?
The metadata export sets the browser tab title and meta description for SEO. Next.js reads this automatically from any layout or page file.
The RootLayout function provides the HTML shell for the entire app. The bg-gray-50 min-h-screen classes give the page a light gray background that fills the viewport.
- Save app/layout.tsx.
Seeing a TypeScript error?
Make sure you kept the import "./globals.css" line. Without it, Tailwind CSS styles will not load. Why is my layout showing a TypeScript error?
Also confirm the file still has the export default keywords before the function. Next.js needs layouts to be a default export.
Replace the home page with a Server Component
Pages in the App Router are Server Components by default. This means they can run database queries directly in the component body, with no API layer needed.
- Open app/page.tsx in VS Code.
- Select all the existing code and replace it with the following:
import { prisma } from "@/lib/prisma";
export default async function Home() {
// Query all bookmarks, newest first
const bookmarks = await prisma.bookmark.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
My Bookmarks
</h1>
<div className="space-y-3">
{bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<div>
<h3 className="font-medium text-gray-900">{bookmark.title}</h3>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
{bookmark.url}
</a>
{bookmark.tag && (
<span className="ml-2 inline-block bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full">
{bookmark.tag}
</span>
)}
</div>
</div>
))}
</div>
</main>
);
}
What does this code do?
The async keyword on the component lets you await the Prisma query directly inside the function. This is the Server Component pattern. The data is fetched on the server before the HTML is sent to the browser.
The prisma.bookmark.findMany call retrieves all bookmarks sorted by createdAt in descending order so the newest appears first.
Each bookmark renders as a white card with rounded corners and a shadow. The URL is a clickable link that opens in a new tab. Tags render as small rounded pills only when present (thanks to the && conditional).
- Save app/page.tsx.
- Make sure your dev server is running. If you stopped it earlier, start it again by running:
npm run dev
- Open http://localhost:3000 in your browser. You should see the "My Bookmarks" heading with an empty list below it (no bookmarks exist yet).
Seeing an import error?
If the dev server shows an error about @/lib/prisma, confirm that lib/prisma.ts exists in the root of your project folder (not inside app/). The @/ alias maps to your project root in Next.js.
Also check that you ran the Prisma migration in the previous step. Without the generated client, the import will fail. Help me fix a Prisma import error in my Next.js Server Component.
Seed the database with sample bookmarks
The page works, but there is nothing to display yet. A seed script populates your database with starter data so you can see the page in action.
- Create a new file called seed.ts inside the prisma/ folder.
- Paste the following code into prisma/seed.ts:
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";
// Create a standalone Prisma instance for seeding
const adapter = new PrismaBetterSqlite3({
url: "file:./prisma/dev.db",
});
const prisma = new PrismaClient({ adapter });
async function main() {
// Insert sample bookmarks into the database
await prisma.bookmark.createMany({
data: [
{
title: "Next.js Documentation",
url: "https://nextjs.org/docs",
tag: "docs",
},
{
title: "Prisma ORM Guide",
url: "https://www.prisma.io/docs",
tag: "docs",
},
{
title: "Tailwind CSS",
url: "https://tailwindcss.com",
tag: "tools",
},
],
});
console.log("Seeded 3 bookmarks.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
What does this code do?
The seed script creates its own PrismaClient instance (separate from the app's singleton) so it can run as a standalone script. It inserts three bookmarks using createMany for efficiency.
Two bookmarks have the tag docs and one has tools. This variety will be useful later when you build the tag filter component.
- Save prisma/seed.ts.
- Run the seed script by executing this command in your terminal:
npx tsx prisma/seed.ts
What does this command do?
The npx tsx command runs a TypeScript file directly without compiling it first. It downloads the tsx runner temporarily via npx and executes your seed file.
- You should see Seeded 3 bookmarks. printed in the terminal.
- Switch to your browser and refresh http://localhost:3000. You should see three bookmark cards: "Next.js Documentation", "Prisma ORM Guide", and "Tailwind CSS". Each card shows a clickable blue URL and a small tag pill.
Don't see the bookmarks?
If the terminal printed an error when running the seed, check that the import path ../generated/prisma/client matches the location of your generated Prisma client. Run npx prisma generate to regenerate it if needed.
If the seed ran successfully but the page still looks empty, do a hard refresh in the browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows). Server Components cache their output between requests during development. Help me debug why my seeded bookmarks are not showing up on the page.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/layout.tsx and app/page.tsx, and that your browser shows three styled bookmark cards.
ⓧ I'd like to double check the full code
Here is the full code for each file at the end of this step.
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Bookmarks Manager",
description: "A personal bookmarks manager built with Next.js 16 and Prisma",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50 min-h-screen">{children}</body>
</html>
);
}
import { prisma } from "@/lib/prisma";
export default async function Home() {
const bookmarks = await prisma.bookmark.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
My Bookmarks
</h1>
<div className="space-y-3">
{bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<div>
<h3 className="font-medium text-gray-900">{bookmark.title}</h3>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
{bookmark.url}
</a>
{bookmark.tag && (
<span className="ml-2 inline-block bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full">
{bookmark.tag}
</span>
)}
</div>
</div>
))}
</div>
</main>
);
}
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";
const adapter = new PrismaBetterSqlite3({
url: "file:./prisma/dev.db",
});
const prisma = new PrismaClient({ adapter });
async function main() {
await prisma.bookmark.createMany({
data: [
{
title: "Next.js Documentation",
url: "https://nextjs.org/docs",
tag: "docs",
},
{
title: "Prisma ORM Guide",
url: "https://www.prisma.io/docs",
tag: "docs",
},
{
title: "Tailwind CSS",
url: "https://tailwindcss.com",
tag: "tools",
},
],
});
console.log("Seeded 3 bookmarks.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Your bookmarks manager now has a styled display page pulling live data from your database. Next up, you will build a form to add new bookmarks without leaving the page.
Add Bookmark Creation with Server Actions
Your bookmarks manager can display data, but there's no way to add new bookmarks yet.
In this step, you'll build a form that saves bookmarks directly to the database using Server Actions. Server Actions let you handle form submissions and database mutations without writing separate API routes.
In this step, get ready to:
- Create a Server Action that writes new bookmarks to the database.
- Build an Add Bookmark form component that submits to the Server Action.
- Test creating a bookmark and seeing it appear instantly in the list.
Create the createBookmark Server Action
A Server Action is a function that runs exclusively on the server. You mark it with the "use server" directive, and Next.js handles all the communication between the browser form and the server function automatically.
- Create a new file called app/actions.ts in VS Code and add the following code:
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createBookmark(formData: FormData) {
// Extract form field values from the submitted FormData
const title = formData.get("title") as string;
const url = formData.get("url") as string;
const tag = (formData.get("tag") as string) || null;
// Guard against empty required fields
if (!title || !url) {
return;
}
// Insert the new bookmark into the database
await prisma.bookmark.create({
data: { title, url, tag },
});
// Tell Next.js to refresh the home page data
revalidatePath("/");
}
What does this code do?
"use server" at the top marks every exported function in this file as a Server Action. When a form calls one of these functions, Next.js sends the FormData to the server behind the scenes.
revalidatePath("/") tells Next.js to re-fetch the data for the home page after the mutation. This is what makes the new bookmark appear in the list without the user refreshing the browser.
Why Server Actions instead of API routes?
Traditional React apps require you to create a separate API endpoint (like /api/bookmarks), then write fetch logic on the client to call it. Server Actions eliminate that boilerplate.
You write a single function, pass it to a form, and Next.js handles the network layer for you. The result is less code and fewer files.
- Save app/actions.ts.
Seeing a red underline on the import?
If VS Code shows an error on import { prisma } from "@/lib/prisma", make sure the lib/prisma.ts file exists from the previous step. The @/ alias maps to your project root.
Help me fix my Server Action import
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createBookmark(formData: FormData) {
const title = formData.get("title") as string;
const url = formData.get("url") as string;
const tag = (formData.get("tag") as string) || null;
if (!title || !url) {
return;
}
await prisma.bookmark.create({
data: { title, url, tag },
});
revalidatePath("/");
}
Build the BookmarkForm component
Now you need a form that collects the bookmark details and submits them to your Server Action. You'll create this as a separate component inside an app/components/ folder to keep your project organized.
- Create a new folder called components inside the app/ directory.
- Create a new file called BookmarkForm.tsx inside app/components/ and add the following code:
import { createBookmark } from "@/app/actions";
export function BookmarkForm() {
return (
<form action={createBookmark} className="mb-8 p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Add Bookmark</h2>
<div className="space-y-3">
{/* Title field - required */}
<input
type="text"
name="title"
placeholder="Title"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* URL field - required, uses type="url" for browser validation */}
<input
type="url"
name="url"
placeholder="https://example.com"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Tag field - optional */}
<input
type="text"
name="tag"
placeholder="Tag (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
Save Bookmark
</button>
</div>
</form>
);
}
What does this code do?
The action={createBookmark} attribute is the key line. It tells the form to call your Server Action when submitted. Next.js automatically packages the form inputs into a FormData object and sends it to the server.
Each name attribute on the inputs ("title", "url", "tag") matches what your Server Action extracts with formData.get(). The required attribute on title and URL gives you free browser-side validation before anything hits the server.
- Save app/components/BookmarkForm.tsx.
Import error on createBookmark?
Make sure you saved app/actions.ts in the previous substep. The import path @/app/actions points to that file.
Help me fix the createBookmark import
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { createBookmark } from "@/app/actions";
export function BookmarkForm() {
return (
<form action={createBookmark} className="mb-8 p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Add Bookmark</h2>
<div className="space-y-3">
<input
type="text"
name="title"
placeholder="Title"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="url"
name="url"
placeholder="https://example.com"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
name="tag"
placeholder="Tag (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
Save Bookmark
</button>
</div>
</form>
);
}
Wire the form into the home page and test it
The form component exists, but it won't appear on the page until you import and render it inside app/page.tsx.
- Switch back to app/page.tsx in VS Code.
- Find this import line at the top of the file:
import { prisma } from "@/lib/prisma";
- Add this import directly below it:
import { BookmarkForm } from "./components/BookmarkForm";
- Next, find the closing </h1> tag inside the return statement.
- Add <BookmarkForm /> on the line directly below it, above the <div className="space-y-3"> line:
<BookmarkForm />
<div className="space-y-3">
What's happening here?
You're placing the form above the bookmark list so it's the first thing the user sees. Because BookmarkForm doesn't have a "use client" directive, it renders as a Server Component. The form still works interactively because HTML forms have built-in submit behavior that Next.js enhances with Server Actions.
- Save app/page.tsx.
- Return to your browser at http://localhost:3000. You should see the "Add Bookmark" form above your existing bookmarks.
- Enter MDN Web Docs in the Title field.
- Enter https://developer.mozilla.org in the URL field.
- Enter docs in the Tag field.
- Click Save Bookmark.
Your new bookmark should appear at the top of the list immediately. The form fields clear after submission, ready for the next bookmark.
Bookmark not appearing after submit?
Check that your app/actions.ts file includes revalidatePath("/") at the end of the function. Without it, Next.js won't know to refresh the page data.
Also confirm the name attributes on your form inputs match exactly what the Server Action expects: "title", "url", and "tag".
Help me debug my bookmark form submission
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
import { prisma } from "@/lib/prisma";
import { BookmarkForm } from "./components/BookmarkForm";
export default async function Home() {
const bookmarks = await prisma.bookmark.findMany({
orderBy: { createdAt: "desc" },
});
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
My Bookmarks
</h1>
<BookmarkForm />
<div className="space-y-3">
{bookmarks.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<div>
<h3 className="font-medium text-gray-900">{bookmark.title}</h3>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
{bookmark.url}
</a>
{bookmark.tag && (
<span className="ml-2 inline-block bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full">
{bookmark.tag}
</span>
)}
</div>
</div>
))}
</div>
</main>
);
}
You can now create bookmarks and see them instantly. Next up, you'll add the ability to delete bookmarks and filter them by tag.
Add Bookmark Deletion and Tag Filtering
Your bookmarks manager can create and display bookmarks. Now it needs the other half of any useful CRUD app: the ability to remove bookmarks and quickly find the ones you care about.
In this step you will add a delete action that removes bookmarks from the database and build a Client Component that filters bookmarks by tag. The delete button uses the same Server Actions pattern you learned in the previous step, while the filter introduces interactive React state on the client.
In this step, get ready to:
- Add a delete Server Action and a delete button component.
- Create a Client Component that filters bookmarks by tag.
- Integrate the filter component into the home page and verify everything works.
Add the delete Server Action and DeleteButton component
Deleting a bookmark follows the same Server Action pattern you used for creation. The action receives the bookmark's ID, removes it from the database, and revalidates the page so the UI updates.
- In VS Code, open app/actions.ts.
- Add the following deleteBookmark function below the existing createBookmark function:
export async function deleteBookmark(id: string) {
await prisma.bookmark.delete({
where: { id },
});
revalidatePath("/");
}
What does this code do?
The deleteBookmark action takes a bookmark id string, calls prisma.bookmark.delete to remove that row from the database, then calls revalidatePath("/") so the home page re-renders with the updated list.
- Save app/actions.ts.
Now you need a button component that calls this action. The button wraps a form whose action attribute uses .bind() to pre-fill the bookmark ID.
- Create a new file at app/components/DeleteButton.tsx and add the following code:
"use client";
import { deleteBookmark } from "@/app/actions";
export function DeleteButton({ id }: { id: string }) {
return (
<form action={deleteBookmark.bind(null, id)}>
<button
type="submit"
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</form>
);
}
Why use .bind() here?
Server Actions invoked via a form action attribute only receive FormData as their argument. Since the delete button has no input fields, you use deleteBookmark.bind(null, id) to pre-fill the bookmark's ID as the first argument.
The "use client" directive makes this a Client Component because event handlers and .bind() require client-side JavaScript.
- Save app/components/DeleteButton.tsx.
Seeing a "cannot use server action in client component" error?
Make sure app/actions.ts has "use server" at the very top of the file. Without it, the function is not recognized as a Server Action and cannot be imported into a Client Component.
Help me debug my DeleteButton component.
Create the tag filter Client Component
The tag filter is where your app gets interactive. It uses React useState to track which tag is selected, then filters the bookmark list client-side without making another server request.
- Create a new file at app/components/BookmarkFilter.tsx and add the following code:
"use client";
import { useState } from "react";
import { DeleteButton } from "./DeleteButton";
type Bookmark = {
id: string;
title: string;
url: string;
tag: string | null;
createdAt: Date;
};
export function BookmarkFilter({
bookmarks,
tags,
}: {
bookmarks: Bookmark[];
tags: string[];
}) {
const [activeTag, setActiveTag] = useState<string | null>(null);
const filtered = activeTag
? bookmarks.filter((b) => b.tag === activeTag)
: bookmarks;
return (
<div>
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setActiveTag(null)}
className={`px-3 py-1 rounded-full text-sm ${
activeTag === null
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
All
</button>
{tags.map((tag) => (
<button
key={tag}
onClick={() => setActiveTag(tag)}
className={`px-3 py-1 rounded-full text-sm ${
activeTag === tag
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{tag}
</button>
))}
</div>
<div className="space-y-3">
{filtered.length === 0 ? (
<p className="text-gray-500 text-center py-8">No bookmarks found.</p>
) : (
filtered.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<div>
<h3 className="font-medium text-gray-900">{bookmark.title}</h3>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
{bookmark.url}
</a>
{bookmark.tag && (
<span className="ml-2 inline-block bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full">
{bookmark.tag}
</span>
)}
</div>
<DeleteButton id={bookmark.id} />
</div>
))
)}
</div>
</div>
);
}
What does this component do?
- activeTag tracks the currently selected tag filter. When it is null, all bookmarks are shown.
- The filtered variable computes which bookmarks to display based on the active tag.
- Tag buttons at the top let the user switch filters. The active button gets a blue highlight.
- Each bookmark card renders a DeleteButton so users can remove entries directly from the list.
Why is this a Client Component?
Server Components cannot use React hooks like useState because they render once on the server with no client-side interactivity. The filter needs to respond to button clicks instantly without a server round-trip, so it must be a Client Component.
The bookmark data is still fetched on the server (in page.tsx) and passed down as props. This keeps database access on the server while interactivity happens on the client.
- Save app/components/BookmarkFilter.tsx.
Seeing a type error on the Bookmark type?
Make sure the Bookmark type definition is inside the same file, above the component function. The createdAt field must be typed as Date to match what Prisma returns.
Help me fix type errors in my BookmarkFilter component.
Integrate the filter into the home page
The final piece is wiring everything together. The home page needs to derive the unique tags from the bookmarks data and pass both into the filter component.
- Open app/page.tsx and replace the entire file contents with the following:
import { prisma } from "@/lib/prisma";
import { BookmarkFilter } from "./components/BookmarkFilter";
import { BookmarkForm } from "./components/BookmarkForm";
import { DeleteButton } from "./components/DeleteButton";
export default async function Home() {
const bookmarks = await prisma.bookmark.findMany({
orderBy: { createdAt: "desc" },
});
const tags = Array.from(
new Set(bookmarks.map((b) => b.tag).filter(Boolean))
) as string[];
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
My Bookmarks
</h1>
<BookmarkForm />
<BookmarkFilter bookmarks={bookmarks} tags={tags} />
</main>
);
}
What changed in the page?
- The old inline bookmark list is gone. BookmarkFilter now handles rendering and filtering all bookmarks.
- The tags variable extracts unique non-null tags from the bookmarks array using a Set. This gives the filter component the list of tag buttons to render.
- Data fetching stays in this Server Component. The bookmarks and tags are passed as props to the Client Component below.
- Save app/page.tsx.
- Return to your browser at http://localhost:3000. You should see tag filter buttons (like All, docs, tools) above your bookmark cards.
- Click a tag button to filter the list. Click All to show everything again.
- Click the red Delete text next to any bookmark. It should disappear from the list immediately.
Delete button not removing the bookmark?
Check that deleteBookmark in app/actions.ts is exported and spelled exactly the same in your DeleteButton.tsx import. Also confirm the function parameter is named id (not bookmarkId or something else).
Help me debug my delete functionality.
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file you modified or created in this step.
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createBookmark(formData: FormData) {
const title = formData.get("title") as string;
const url = formData.get("url") as string;
const tag = (formData.get("tag") as string) || null;
if (!title || !url) {
return;
}
await prisma.bookmark.create({
data: { title, url, tag },
});
revalidatePath("/");
}
export async function deleteBookmark(id: string) {
await prisma.bookmark.delete({
where: { id },
});
revalidatePath("/");
}
"use client";
import { deleteBookmark } from "@/app/actions";
export function DeleteButton({ id }: { id: string }) {
return (
<form action={deleteBookmark.bind(null, id)}>
<button
type="submit"
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</form>
);
}
"use client";
import { useState } from "react";
import { DeleteButton } from "./DeleteButton";
type Bookmark = {
id: string;
title: string;
url: string;
tag: string | null;
createdAt: Date;
};
export function BookmarkFilter({
bookmarks,
tags,
}: {
bookmarks: Bookmark[];
tags: string[];
}) {
const [activeTag, setActiveTag] = useState<string | null>(null);
const filtered = activeTag
? bookmarks.filter((b) => b.tag === activeTag)
: bookmarks;
return (
<div>
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setActiveTag(null)}
className={`px-3 py-1 rounded-full text-sm ${
activeTag === null
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
All
</button>
{tags.map((tag) => (
<button
key={tag}
onClick={() => setActiveTag(tag)}
className={`px-3 py-1 rounded-full text-sm ${
activeTag === tag
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{tag}
</button>
))}
</div>
<div className="space-y-3">
{filtered.length === 0 ? (
<p className="text-gray-500 text-center py-8">No bookmarks found.</p>
) : (
filtered.map((bookmark) => (
<div
key={bookmark.id}
className="p-4 bg-white rounded-lg shadow flex items-center justify-between"
>
<div>
<h3 className="font-medium text-gray-900">{bookmark.title}</h3>
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
{bookmark.url}
</a>
{bookmark.tag && (
<span className="ml-2 inline-block bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full">
{bookmark.tag}
</span>
)}
</div>
<DeleteButton id={bookmark.id} />
</div>
))
)}
</div>
</div>
);
}
import { prisma } from "@/lib/prisma";
import { BookmarkFilter } from "./components/BookmarkFilter";
import { BookmarkForm } from "./components/BookmarkForm";
import { DeleteButton } from "./components/DeleteButton";
export default async function Home() {
const bookmarks = await prisma.bookmark.findMany({
orderBy: { createdAt: "desc" },
});
const tags = Array.from(
new Set(bookmarks.map((b) => b.tag).filter(Boolean))
) as string[];
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">
My Bookmarks
</h1>
<BookmarkForm />
<BookmarkFilter bookmarks={bookmarks} tags={tags} />
</main>
);
}
Your bookmarks manager is fully functional. You can create, view, filter, and delete bookmarks using a combination of Server Components, Client Components, and Server Actions. Ready to take it further? The Secret Mission challenges you to add real-time search.
Secret mission
Add Real-Time Search
Your bookmarks manager works great with tag filtering, but what happens when you have dozens of bookmarks under the same tag? In this extension, you'll add a search bar that filters bookmarks by title in real time and combines with the tag filter for precision lookups.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database, so there are no ongoing costs.
Resources you used:
- Next.js project folder on your Desktop (contains all source code, node_modules, prisma/dev.db, and generated Prisma client).
- Local dev server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with your bookmarks manager.
- Your project folder stays on your Desktop.
- The SQLite database (prisma/dev.db) retains all your bookmarks.
- You can restart the dev server anytime with npm run dev from the project folder.
Pause - I'll come back to this later
Shut down the dev server to free up your terminal. Your files and database stay intact for next time.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- When you're ready to resume, open the project folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your source code, database, and all bookmarks permanently.
- Stop the dev server by pressing Ctrl+C in the terminal (if still running).
- Close the project in VS Code.
- Delete the project folder by running this command from your Desktop:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder-name"]]
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) and checking that the project folder no longer appears.
Nice Work!
Nice Work!
You just built a fully functional personal bookmarks manager from scratch using the modern Next.js 16 stack. From database schema to interactive UI, this app covers the same patterns professional teams ship with every day.
You've learned how to:
- Build a fullstack Next.js 16 app using the App Router, Server Components for data fetching, and Server Actions for mutations without writing API routes.
- Define a database schema with Prisma ORM and connect it to a local SQLite database using the driver adapter pattern.
- Build a Client Component with interactive tag filtering powered by React state.
- Secret Mission: Added real-time search with combined tag and text filtering in a single Client Component.
Ready to quiz yourself?