Build a Bookmarks Manager with Next.js

Build a full-stack bookmarks app with auth, CRUD, and a JSON API.

Introduction

30 Second Summary

You probably have dozens of browser tabs open right now with articles, tools, and tutorials you want to come back to. Saving them as browser bookmarks works until you switch devices or lose track of what you saved.

In this project, you will build a full-stack bookmarks manager using Next.js and a local SQLite database. You will create pages for logging in, browsing your saved links, adding new bookmarks, and viewing individual details.

What You'll Build

You log in to your personal dashboard, save a new link, watch it appear in the list, click through to its detail page, and hit a JSON API endpoint that returns all your bookmarks.

By the end of this project, you'll have:

  • A live dashboard rendered by a Server Component that pulls your bookmarks straight from the database, wrapped in nested layouts with a persistent sidebar.
  • An interactive add-bookmark form built as a Client Component with a Server Action that saves new links to the database and refreshes the list instantly. Delete buttons that remove bookmarks with a single click.
  • Cookie-based login with a proxy that redirects anonymous visitors to sign in. Dynamic detail pages with error handling and 404 pages, plus a Route Handler at /api/bookmarks returning JSON.
  • Secret Mission: add bookmark categories with a filterable dropdown that lets you browse by topic.

Are there any prerequisites?

This project assumes you are comfortable with React components, hooks, and state. You also need Node.js 20.9 or higher and VS Code installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Scaffold Next.js and Set Up Prisma with SQLite

You have a clear picture of what you are building. Now you need a running app and a database before you can build any features.

In this step, you will scaffold a Next.js 16 project and connect it to a local SQLite database using Prisma 7. SQLite runs as a single file on your machine, so there is zero cloud cost and zero setup friction.

In this step, get ready to:
  • Scaffold a Next.js 16 project and confirm the starter page loads.
  • Install Prisma with a SQLite adapter and define a Bookmark model.
  • Seed the database with sample bookmarks and verify them in Prisma Studio.
Create the Next.js project

Every terminal command in this project runs inside your project folder. First, you need to create that folder on your Desktop so you can find it easily.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest bookmarks-app --yes

What does this command do?

npx create-next-app@latest downloads and runs the latest Next.js scaffolding tool. bookmarks-app is the folder name for your project.

The --yes flag accepts all defaults: TypeScript, Tailwind CSS, ESLint, and App Router enabled. No interactive prompts will appear.

  • Move into your new project folder by running:
cd bookmarks-app
  • Start the development server by running:
npm run dev
  • Open your browser and visit http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and links to documentation.

Don't see the starter page?

  • Make sure the terminal shows Ready and a local URL. If it shows an error, check that port 3000 is not already in use by another application.
  • Confirm you ran cd bookmarks-app before npm run dev. Running the dev command from the wrong folder causes a missing package.json error.
  • help me debug why my Next.js starter page is not loading
Install Prisma and configure SQLite

Your app is running, but it has no database. Prisma is an ORM that gives you type-safe database queries in TypeScript. You will connect it to SQLite, which stores everything in a single file.

  • Stop the development server by pressing Ctrl+C in your terminal.
  • Install Prisma and the SQLite type definitions as dev dependencies by running:
npm install prisma @types/better-sqlite3 --save-dev
  • Install the Prisma client, the SQLite adapter, and dotenv by running:
npm install @prisma/client @prisma/adapter-better-sqlite3 dotenv

You should see both install commands complete without errors.

Seeing install errors?

  • Make sure you are inside the bookmarks-app folder. Run ls and confirm you see package.json in the output.
  • help me fix npm install errors in my Next.js project
  • Initialize Prisma with SQLite by running:
npx prisma init --datasource-provider sqlite --output ../generated/prisma

What did this create?

  • prisma/schema.prisma is where you define your data models.
  • prisma.config.ts configures the Prisma CLI with your database URL and migration path.
  • .env stores your database connection string: DATABASE_URL="file:./dev.db".

The generated schema.prisma has the generator and datasource blocks but no data models yet. You need to add a Bookmark model so Prisma knows what table to create.

  • In the VS Code Explorer sidebar, expand the prisma folder and click schema.prisma to open it.
  • Replace the entire file contents with the following code:
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Bookmark {
  id        Int      @id @default(autoincrement())
  title     String
  url       String
  createdAt DateTime @default(now())
}

What does this schema do?

The generator block tells Prisma to output the client code to generated/prisma. The datasource block sets the database provider to SQLite.

The Bookmark model defines a table with four columns: an auto-incrementing id, a title, a url, and a createdAt timestamp that defaults to the current time.

  • Save schema.prisma.
  • Create the database and apply the schema by running this migration command:
npx prisma migrate dev --name init

What does this command do?

npx prisma migrate dev reads your schema, generates a SQL migration file in prisma/migrations, and applies it to create the dev.db SQLite database file. The --name init flag labels this first migration.

You should see output confirming the migration was applied successfully.

  • Generate the Prisma client by running:
npx prisma generate

This creates the type-safe client code inside generated/prisma/ that your app will import.

Migration or generate failed?

  • Check that your schema.prisma file matches the code above exactly. A missing bracket or typo in the model name causes failures.
  • Confirm the .env file in your project root contains DATABASE_URL="file:./dev.db". This was auto-generated by prisma init.
  • help me debug Prisma migration errors
Create a Prisma client utility and seed data

The database exists, but your app has no way to talk to it yet. You need a shared Prisma client file that any part of your app can import. You also need some sample data so you can see bookmarks on the dashboard later.

  • In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder lib.
  • Right-click the new lib folder and select New File.
  • Name the file prisma.ts.
  • Paste the following code into app/lib/prisma.ts:
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../../generated/prisma/client";

// Create a SQLite adapter pointing at the DATABASE_URL from .env
const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` });
// Instantiate PrismaClient with the adapter
const prisma = new PrismaClient({ adapter });

export default prisma;

What does this file do?

Prisma 7 requires a driver adapter for all database connections. PrismaBetterSqlite3 is the adapter for SQLite. It reads the database path from your .env file.

By exporting a single prisma instance, every file in your app reuses the same database connection instead of opening a new one.

  • Save app/lib/prisma.ts.

Now create the seed script that populates the database with sample bookmarks.

  • In the Explorer sidebar, right-click the prisma folder and select New File.
  • Name the file seed.ts.
  • Paste the following code into prisma/seed.ts:
import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";

// Create a standalone Prisma client for seeding
const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` });
const prisma = new PrismaClient({ adapter });

async function main() {
  // Insert 3 sample bookmarks
  await prisma.bookmark.createMany({
    data: [
      { title: "Next.js Docs", url: "https://nextjs.org/docs" },
      { title: "Prisma Docs", url: "https://www.prisma.io/docs" },
      { title: "TypeScript Handbook", url: "https://www.typescriptlang.org/docs/" },
    ],
  });
  console.log("Seeded 3 bookmarks");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Why does the seed file create its own Prisma client?

The seed script runs as a standalone Node.js script, not inside your Next.js app. It needs its own PrismaClient instance with the SQLite adapter to connect to the same database.

  • Save prisma/seed.ts.
  • Run the seed script by executing:
npx tsx prisma/seed.ts

You should see Seeded 3 bookmarks printed in your terminal.

Seeing a module or import error?

  • Confirm that dotenv was installed. Run npm list dotenv and check it appears in the output.
  • Make sure the import path in seed.ts is "../generated/prisma/client" (one level up from the prisma folder), not two levels up.
  • help me fix import errors in my Prisma seed script

Before you trust the terminal output, verify the data is actually in the database.

  • Open Prisma Studio to browse your database by running:
npx prisma studio

What do you expect to see? Take a moment to predict before looking at the Studio interface.

Prisma Studio opens in your browser and shows your Bookmark table. Click on Bookmark and you should see 3 rows: Next.js Docs, Prisma Docs, and TypeScript Handbook.

Prisma Studio shows an empty table?

  • Make sure you ran npx tsx prisma/seed.ts and saw Seeded 3 bookmarks in the terminal output before opening Studio.
  • Check that .env contains DATABASE_URL="file:./dev.db". The seed script and Studio both read from this file.
  • help me debug why Prisma Studio shows no data
  • Close Prisma Studio by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/lib/prisma.ts and prisma/seed.ts.

ⓧ I'd like to double check the full code

Here are the files you created in this step. Scroll up and confirm each one matches.

prisma/schema.prisma:

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Bookmark {
  id        Int      @id @default(autoincrement())
  title     String
  url       String
  createdAt DateTime @default(now())
}

prisma.config.ts (auto-generated, at the project root):

import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

app/lib/prisma.ts:

import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../../generated/prisma/client";

const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` });
const prisma = new PrismaClient({ adapter });

export default prisma;

prisma/seed.ts:

import "dotenv/config";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "../generated/prisma/client";

const adapter = new PrismaBetterSqlite3({ url: `${process.env.DATABASE_URL}` });
const prisma = new PrismaClient({ adapter });

async function main() {
  await prisma.bookmark.createMany({
    data: [
      { title: "Next.js Docs", url: "https://nextjs.org/docs" },
      { title: "Prisma Docs", url: "https://www.prisma.io/docs" },
      { title: "TypeScript Handbook", url: "https://www.typescriptlang.org/docs/" },
    ],
  });
  console.log("Seeded 3 bookmarks");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Your development environment is fully wired up. You have a running Next.js app, a SQLite database with a Bookmark table, and 3 sample bookmarks ready to display. Next up, you will build the bookmarks dashboard so you can actually see them in the browser.

Build the Bookmarks Dashboard with Layouts and Server Components

Your database has 3 bookmarks sitting in it, but the app still shows the default Next.js starter page. There is no way to actually see your data.

In this step, you will build the routing structure that displays your bookmarks. You will use Server Components to fetch data directly from the database, and nested layouts to add persistent navigation around your pages.

In this step, get ready to:
  • Update the root layout and create a nested bookmarks layout with sidebar navigation.
  • Create the bookmarks list page as a Server Component that fetches from the database.
  • Replace the home page with a landing page that links to the dashboard.
Create the root layout and bookmarks nested layout

Every Next.js app has a root layout at app/layout.tsx that wraps every page. Right now it still has the default scaffolding. You will customize it with a site header, then create a second layout inside the bookmarks folder that adds a sidebar to every bookmarks page.

  • In VS Code, open app/layout.tsx.
  • Find the metadata object near the top of the file. It currently looks like this:
export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};
  • Replace the title and description values so the metadata looks like this:
export const metadata: Metadata = {
  title: "Bookmarks Manager",
  description: "A personal bookmarks manager built with Next.js",
};
  • Next, scroll down to the <body> tag. Inside it you will see a single {children} line:
        {children}
  • Replace that single {children} line with a site header and a <main> wrapper:
        <header className="bg-gray-900 text-white p-4">
          <h1 className="text-xl font-bold">Bookmarks Manager</h1>
        </header>
        <main>{children}</main>

What does this change do?

The root layout wraps every single page in your app. By adding a <header> here, the dark "Bookmarks Manager" bar appears at the top of every route automatically.

The <main> element wraps {children}, which is where Next.js injects each page's content. This separation keeps the header persistent while the main area changes per route.

  • Save app/layout.tsx.
  • Switch back to your browser at http://localhost:3000. You should see a dark header bar with the text "Bookmarks Manager" at the top of the page.

Don't see the header?

  • Make sure you saved app/layout.tsx. The dev server auto-refreshes on save.
  • Check that the <header> is inside the <body> tag, not outside it.
  • help me debug my root layout changes

✔️ Awesome, I've got everything!

Great. Double check you saved app/layout.tsx before moving on.

ⓧ I'd like to double check the full code

Scroll back up and confirm you made both edits to app/layout.tsx:

  • The metadata object has title: "Bookmarks Manager" and the updated description.
  • Inside the <body> tag, the <header> comes first, then <main>{children}</main>.

Now you will create a nested layout for the bookmarks section. In Next.js, any layout.tsx file inside a route folder wraps all pages in that folder. This means you can add a sidebar that stays visible across all /bookmarks/* routes.

  • In VS Code, create a new folder inside app called bookmarks.
  • Inside app/bookmarks, create a new file called layout.tsx.
  • Paste the following code into app/bookmarks/layout.tsx:
import Link from "next/link";

// Nested layout wraps all /bookmarks/* routes
export default function BookmarksLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-h-[calc(100vh-64px)]">
      {/* Sidebar navigation */}
      <aside className="w-64 bg-gray-100 p-6 border-r">
        <nav className="flex flex-col gap-4">
          <Link href="/bookmarks" className="text-blue-600 hover:underline">
            All Bookmarks
          </Link>
          <Link href="/bookmarks/add" className="text-blue-600 hover:underline">
            Add New
          </Link>
        </nav>
      </aside>
      {/* Main content area for child pages */}
      <section className="flex-1 p-8">{children}</section>
    </div>
  );
}

What is a nested layout?

Next.js uses file-system routing. Any layout.tsx in a folder automatically wraps all pages and child layouts in that folder. The root layout already wraps everything with the header.

This bookmarks layout adds a sidebar with two navigation links. When you visit any /bookmarks page, you will see the root header on top and this sidebar on the left. The page content renders inside {children}.

  • Save app/bookmarks/layout.tsx.

✔️ Awesome, I've got everything!

Great. You will see this layout in action in the next substep when you create the bookmarks page.

ⓧ I'd like to double check the full code

import Link from "next/link";

export default function BookmarksLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-h-[calc(100vh-64px)]">
      <aside className="w-64 bg-gray-100 p-6 border-r">
        <nav className="flex flex-col gap-4">
          <Link href="/bookmarks" className="text-blue-600 hover:underline">
            All Bookmarks
          </Link>
          <Link href="/bookmarks/add" className="text-blue-600 hover:underline">
            Add New
          </Link>
        </nav>
      </aside>
      <section className="flex-1 p-8">{children}</section>
    </div>
  );
}
Create the bookmarks list page as a Server Component

With the layout in place, you need a page to show the actual bookmarks. In the App Router, pages are Server Components by default. This means they can fetch data directly from your database at render time, with no API layer in between.

  • Inside app/bookmarks, create a new file called page.tsx.
  • Set up the data-fetching layer by pasting this code into app/bookmarks/page.tsx:
import Link from "next/link";
import prisma from "@/app/lib/prisma";

// Async Server Component - fetches data on the server
export default async function BookmarksPage() {
  // Query all bookmarks, newest first
  const bookmarks = await prisma.bookmark.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <div>
      <h2 className="text-2xl font-bold mb-6">Your Bookmarks</h2>
    </div>
  );
}

What makes this a Server Component?

There is no 'use client' directive at the top of this file. By default, every component in the App Router runs on the server. That is why you can call prisma.bookmark.findMany() directly inside the component. No fetch calls, no API routes needed.

The async keyword on the function lets you await the database query. Next.js streams the rendered HTML to the browser once the data is ready.

  • Save app/bookmarks/page.tsx and visit http://localhost:3000/bookmarks in your browser.
  • You should see the dark header, a gray sidebar on the left with "All Bookmarks" and "Add New" links, and the heading "Your Bookmarks" in the main content area.

Seeing a 404 or blank page?

  • Make sure the file is named exactly page.tsx (not page.ts or index.tsx). Next.js requires page.tsx for route pages.
  • Confirm the file is inside app/bookmarks/, not app/ directly.
  • help me fix my bookmarks page not loading

The page works, but it only shows a heading. Now you will add the code that renders each bookmark as a card.

  • In app/bookmarks/page.tsx, find the <h2> line inside the return statement. Replace everything inside the <div> (from the <h2> down to the closing </div>) with this code:
      <h2 className="text-2xl font-bold mb-6">Your Bookmarks</h2>
      {/* Show empty state or bookmark list */}
      {bookmarks.length === 0 ? (
        <p className="text-gray-500">No bookmarks yet. Add one!</p>
      ) : (
        <ul className="space-y-4">
          {bookmarks.map((bookmark) => (
            <li
              key={bookmark.id}
              className="border rounded-lg p-4 flex justify-between items-center"
            >
              <div>
                {/* Link to bookmark detail page */}
                <Link
                  href={`/bookmarks/${bookmark.id}`}
                  className="text-lg font-medium text-blue-600 hover:underline"
                >
                  {bookmark.title}
                </Link>
                <p className="text-sm text-gray-500">{bookmark.url}</p>
              </div>
            </li>
          ))}
        </ul>
      )}

What does this code do?

This JSX conditionally renders the bookmark list. If there are no bookmarks, it shows a placeholder message. Otherwise, it maps over each bookmark and renders a card.

Each card displays the bookmark title as a clickable Link to /bookmarks/[id] (a detail page you will build in a later step) and the URL in smaller gray text below it.

  • Save app/bookmarks/page.tsx and refresh http://localhost:3000/bookmarks.
  • You should see 3 bookmark cards listed: "Next.js Docs", "Prisma Docs", and "TypeScript Handbook". Each card shows the title and its URL.

See the heading but no bookmark cards?

  • Check that your <h2> line is still inside the <div> and the conditional rendering code follows it directly.
  • Make sure the Prisma query is still present above the return statement. Without it, bookmarks would be undefined.
  • Verify you seeded the database in Step 1 by checking npx prisma studio for rows.
  • help me debug my bookmark list not rendering

✔️ Awesome, I've got everything!

Great. Double check you saved app/bookmarks/page.tsx before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/bookmarks/page.tsx has both chunks in order:

  • The imports (Link and prisma) and the async function BookmarksPage() with the prisma.bookmark.findMany() query.
  • The return block with the <h2> heading, the empty-state check, and the bookmarks.map() rendering each card with a Link and URL.
Create the home page with a link to the dashboard

The default home page at / still shows the Next.js starter content. You will replace it with a clean landing page that directs visitors to the bookmarks dashboard.

  • Open app/page.tsx in VS Code.
  • Select all the existing code and replace it with the following:
import Link from "next/link";

export default function Home() {
  return (
    // Centered landing page with link to dashboard
    <div className="flex flex-col items-center justify-center min-h-[80vh] gap-6">
      <h2 className="text-3xl font-bold">Welcome to Bookmarks Manager</h2>
      <p className="text-gray-600">Save and organize your favorite links.</p>
      <Link
        href="/bookmarks"
        className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
      >
        Go to Dashboard
      </Link>
    </div>
  );
}

What does this page do?

This replaces the default Next.js starter page with a simple centered landing page. The Link component navigates to /bookmarks using client-side navigation, meaning the browser does not do a full page reload. The root layout header stays in place across both pages.

  • Save app/page.tsx.
  • Visit http://localhost:3000 in your browser. You should see a centered page with "Welcome to Bookmarks Manager" and a blue "Go to Dashboard" button.
  • Click the Go to Dashboard button.

Before you look at the result, what do you expect to see? Think about the two layouts you created and the bookmarks page.

You should land on /bookmarks and see all three elements working together: the dark header from the root layout, the gray sidebar from the bookmarks layout, and your 3 seed bookmarks rendered in the main content area.

Clicking the button does nothing?

  • Make sure you imported Link from next/link at the top of the file, not from react.
  • Check that the href is set to /bookmarks (with a leading slash).
  • help me debug my home page link to bookmarks

✔️ Awesome, I've got everything!

Great. Double check you saved app/page.tsx before moving on.

ⓧ I'd like to double check the full code

import Link from "next/link";

export default function Home() {
  return (
    <div className="flex flex-col items-center justify-center min-h-[80vh] gap-6">
      <h2 className="text-3xl font-bold">Welcome to Bookmarks Manager</h2>
      <p className="text-gray-600">Save and organize your favorite links.</p>
      <Link
        href="/bookmarks"
        className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
      >
        Go to Dashboard
      </Link>
    </div>
  );
}

Your dashboard is alive. You have a root layout, a nested layout with navigation, and a Server Component pulling data straight from your database. Next up, you will make this dashboard interactive by adding forms to create and delete bookmarks.

Add and Delete Bookmarks with Server Actions and Client Components

Your bookmarks dashboard shows data from the database, but right now it is read-only. There is no way to add a new bookmark or remove one you no longer need.

Server Actions let you write mutation logic that runs on the server, directly inside your Next.js app. In this step, you will create Server Actions for adding and deleting bookmarks, then wire them up to interactive Client Components.

In this step, get ready to:
  • Create Server Actions for adding and deleting bookmarks.
  • Build an add-bookmark form as a Client Component.
  • Add delete buttons to the bookmarks list.
Create Server Actions for add and delete

The sidebar already has an "Add New" link, but clicking it leads nowhere. You need functions that can insert and remove bookmarks in the database.

Server Actions are async functions marked with the 'use server' directive. They run entirely on the server and can be called directly from forms, so you do not need to build a separate API endpoint for every mutation.

  • Create a new file at app/lib/actions.ts and add the following code:
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import prisma from "./prisma";

export async function addBookmark(formData: FormData) {
  const title = formData.get("title") as string; // Extract form fields
  const url = formData.get("url") as string;

  if (!title || !url) {
    throw new Error("Title and URL are required");
  }

  await prisma.bookmark.create({
    data: { title, url },
  });

  revalidatePath("/bookmarks"); // Refresh the bookmarks page cache
  redirect("/bookmarks");
}

export async function deleteBookmark(id: number) {
  await prisma.bookmark.delete({
    where: { id },
  });

  revalidatePath("/bookmarks"); // Refresh after deletion
}

What does this code do?

"use server" at the top marks every exported function in this file as a Server Action. They run on the server, never in the browser.

  • addBookmark extracts title and url from the form's FormData, validates them, then inserts a new row using Prisma.
  • deleteBookmark takes a bookmark id and removes the matching row from the database.
  • Both functions call revalidatePath("/bookmarks") after mutating data. This tells Next.js to refetch the bookmarks page so it shows the latest data.
  • addBookmark also calls redirect("/bookmarks") to send the user back to the dashboard after adding.
  • Save app/lib/actions.ts.

What is revalidatePath?

Server Components cache their data by default. When you add or delete a bookmark, the cached list becomes stale.

revalidatePath tells Next.js to throw away the cache for that route so the next request fetches fresh data from the database.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/lib/actions.ts before moving on.

ⓧ I'd like to double check the full code

"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import prisma from "./prisma";

export async function addBookmark(formData: FormData) {
  const title = formData.get("title") as string;
  const url = formData.get("url") as string;

  if (!title || !url) {
    throw new Error("Title and URL are required");
  }

  await prisma.bookmark.create({
    data: { title, url },
  });

  revalidatePath("/bookmarks");
  redirect("/bookmarks");
}

export async function deleteBookmark(id: number) {
  await prisma.bookmark.delete({
    where: { id },
  });

  revalidatePath("/bookmarks");
}
Build the add-bookmark form as a Client Component

With the Server Actions ready, you need a form that calls addBookmark when submitted. Forms with interactive state like controlled inputs require a Client Component, because React hooks like useState only run in the browser.

  • Create a new file at app/bookmarks/add/page.tsx and add the following code:
"use client";

import { useState } from "react";
import { addBookmark } from "@/app/lib/actions";

export default function AddBookmarkPage() {
  const [title, setTitle] = useState(""); // Track input state
  const [url, setUrl] = useState("");
  return (
    <div>
      <h2 className="text-2xl font-bold mb-6">Add a Bookmark</h2>
      <form action={addBookmark} className="space-y-4 max-w-md">
        <div>
          <label htmlFor="title" className="block text-sm font-medium mb-1">
            Title
          </label>
          <input
            id="title"
            name="title"
            type="text"
            required
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full border rounded-lg px-3 py-2"
          />
        </div>
      </form>
    </div>
  );
}

What does this code do?

  • "use client" tells Next.js this is a Client Component. Client Components run in the browser and can use React hooks like useState.
  • useState tracks the title and URL values as the user types.
  • The form's action prop is set to addBookmark. When the form submits, Next.js automatically packages the input values into FormData and passes them to the Server Action.
  • Save app/bookmarks/add/page.tsx.
  • In app/bookmarks/add/page.tsx, find the closing </div> after the title input (just above </form>).
  • Add the URL field and submit button below it, before </form>:
        <div>
          <label htmlFor="url" className="block text-sm font-medium mb-1">
            URL
          </label>
          <input
            id="url"
            name="url"
            type="url"
            required
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            className="w-full border rounded-lg px-3 py-2"
          />
        </div>
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
        >
          Add Bookmark
        </button>

What does this add?

  • The URL input uses type="url" so the browser validates the format before submitting.
  • Clicking the submit button triggers the form's action, which calls addBookmark on the server.
  • Save app/bookmarks/add/page.tsx.

✔️ Awesome, I've got everything!

Great. Confirm you have saved the file before testing.

ⓧ I'd like to double check the full code

Your app/bookmarks/add/page.tsx file has two chunks: the component shell with the title field (from the first code block) and the URL field plus submit button (from the second). Scroll up and confirm both are present in order. The </div> closing the title field should be followed by the <div> opening the URL field, then the <button>, then </form>.

Before you test, think about what should happen when you submit this form. Where will you end up, and what will you see?

  • In your browser, go to http://localhost:3000/bookmarks.
  • Click **Add New** in the sidebar.
  • Enter MDN Web Docs in the Title field.
  • Enter https://developer.mozilla.org in the URL field.
  • Click Add Bookmark.

You should be redirected back to the bookmarks list. MDN Web Docs now appears at the top of your bookmarks.

Don't see the new bookmark?

  • Make sure you saved both app/lib/actions.ts and app/bookmarks/add/page.tsx.
  • Check that the name attribute on each input (name="title" and name="url") matches exactly what addBookmark reads from formData.get().
  • Confirm your dev server is still running in the terminal.

Still stuck? Help me debug why my add-bookmark form is not saving to the database.

Add delete buttons to the bookmark list

You can add bookmarks now, but there is no way to remove them. You will create a small Client Component for the delete button, then add it to each bookmark in the list.

  • Create a new file at app/bookmarks/delete-button.tsx and add the following code:
"use client";

import { deleteBookmark } from "@/app/lib/actions";

export default function DeleteButton({ id }: { id: number }) {
  // Pre-bind the bookmark ID so the form action knows which bookmark to delete
  const deleteWithId = deleteBookmark.bind(null, id);

  return (
    <form action={deleteWithId}>
      <button
        type="submit"
        className="text-red-500 hover:text-red-700 text-sm"
      >
        Delete
      </button>
    </form>
  );
}

What does this code do?

  • DeleteButton is a Client Component because it needs to call a Server Action from a form.
  • deleteBookmark.bind(null, id) creates a new function with the bookmark's id already baked in. When the form submits, Next.js passes the bound id as the first argument to deleteBookmark.
  • Save app/bookmarks/delete-button.tsx.

Why use .bind() instead of passing the ID directly?

Server Actions called from a form's action prop receive FormData as their only argument. Using .bind() lets you prepend extra arguments (like the bookmark ID) before the FormData.

Now you need to import and render DeleteButton inside the bookmarks list page.

  • In app/bookmarks/page.tsx, find this import line near the top:
import prisma from "@/app/lib/prisma";
  • Add the following import directly below it:
import DeleteButton from "./delete-button";
  • Next, scroll down inside the .map() callback. Find this closing </div> just above </li>:
              </div>
            </li>
  • Add the DeleteButton component between </div> and </li>, so the code now looks like this:
              </div>
              <DeleteButton id={bookmark.id} />
            </li>
  • Save app/bookmarks/page.tsx.

✔️ Awesome, I've got everything!

Great. Confirm both delete-button.tsx and page.tsx are saved.

ⓧ I'd like to double check the full code

Here is the complete app/bookmarks/delete-button.tsx:

"use client";

import { deleteBookmark } from "@/app/lib/actions";

export default function DeleteButton({ id }: { id: number }) {
  const deleteWithId = deleteBookmark.bind(null, id);

  return (
    <form action={deleteWithId}>
      <button
        type="submit"
        className="text-red-500 hover:text-red-700 text-sm"
      >
        Delete
      </button>
    </form>
  );
}

For app/bookmarks/page.tsx, check that you have the import DeleteButton from "./delete-button" line near the top, and <DeleteButton id={bookmark.id} /> after the closing </div> inside each <li>.

Before you click delete, what do you expect will happen to the bookmark and the list?

  • In your browser, go to http://localhost:3000/bookmarks.
  • You should see a Delete button next to each bookmark.
  • Click Delete next to any bookmark.

The bookmark disappears from the list instantly. The page refreshes automatically because deleteBookmark calls revalidatePath("/bookmarks").

Delete button not showing up?

  • Confirm you saved both app/bookmarks/delete-button.tsx and app/bookmarks/page.tsx.
  • Check that the import path in page.tsx is "./delete-button" (relative, not @/app/...).
  • Make sure <DeleteButton id={bookmark.id} /> is placed between </div> and </li>, not inside the <div> that wraps the title and URL.

Still stuck? Help me debug why my delete button is not appearing or not working on the bookmarks list.

Your bookmarks app now has full create-and-delete functionality. Next up, you will build a detail page for individual bookmarks and add proper error handling so your app does not crash when something goes wrong.

Add the Detail Page, Error Handling, and Route Handler

Your bookmarks dashboard can list, add, and delete bookmarks. But clicking a bookmark title leads nowhere because there is no page at /bookmarks/[id] yet.

When you do build that detail page, what happens if someone visits a bookmark ID that does not exist? Right now, the app would crash with an ugly default error. In this step, you will create the detail page, intentionally hit that crash, then fix it with proper error boundaries. You will also add a JSON API endpoint for your bookmarks.

In this step, get ready to:
  • Create a dynamic detail page that shows individual bookmark information.
  • Add error.tsx and not-found.tsx error boundaries for graceful error handling.
  • Build a Route Handler that returns bookmarks as JSON.
Create the dynamic detail page and hit the crash

Next.js uses folder names wrapped in square brackets to create dynamic routes. A folder named [id] matches any value in the URL and passes it to your page as a parameter.

  • In the VS Code Explorer sidebar, right-click the app/bookmarks folder and select New Folder.
  • Name the folder [id] (including the square brackets).
  • Right-click your new [id] folder and select New File.
  • Name the file page.tsx and paste the following code:
import Link from "next/link";
import prisma from "@/app/lib/prisma";
export default async function BookmarkDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params; // Unwrap async params
  const bookmark = await prisma.bookmark.findUnique({
    where: { id: parseInt(id) },
  });
  if (!bookmark) {
    throw new Error("Bookmark not found"); // No error boundary yet
  }
  return (
    <div>
      <Link href="/bookmarks" className="text-blue-600 hover:underline mb-4 inline-block">
        Back to Bookmarks
      </Link>
      <h2 className="text-2xl font-bold mb-2">{bookmark.title}</h2>
      <a href={bookmark.url} target="_blank" rel="noopener noreferrer"
        className="text-blue-500 hover:underline">
        {bookmark.url}
      </a>
      <p className="text-gray-500 mt-4 text-sm">
        Added on {bookmark.createdAt.toLocaleDateString()}
      </p>
    </div>
  );
}

What does this code do?

This is an async Server Component that fetches a single bookmark from the database. A few things to notice:

  • params is a Promise in Next.js 16, so you must await it before accessing id.
  • prisma.bookmark.findUnique() returns null when no record matches the ID.
  • Right now, when bookmark is null, the code throws a raw error. There is no error boundary to catch it gracefully.
  • Save app/bookmarks/[id]/page.tsx.
  • In your browser, click any bookmark title on the /bookmarks list page.

You should see the bookmark's title, its URL as a clickable link, and the date it was added.

Don't see the detail page?

  • Make sure the folder is named exactly [id] with the square brackets included. Without brackets, Next.js treats it as a static route.
  • Check that your file is named page.tsx (not Page.tsx or index.tsx). Next.js App Router requires the exact name page.tsx.
  • help me debug my bookmark detail page

Now for the key moment. Before you try this next step, predict what you think will happen when you visit a bookmark ID that does not exist in your database.

  • Type http://localhost:3000/bookmarks/999 in your browser's address bar and press Enter.

You see an ugly, unstyled error page. The app crashed because throw new Error("Bookmark not found") fired, and nothing caught it. This is the problem you will fix next.

Add error boundaries with not-found.tsx and error.tsx

Next.js provides two special file conventions for handling errors gracefully. The not-found.tsx file renders a friendly 404 page, and error.tsx catches unexpected runtime errors with a retry option.

  • In app/bookmarks/[id]/page.tsx, find this import line at the top of the file:
import prisma from "@/app/lib/prisma";
  • Add a new import directly above it, so your imports look like this:
import { notFound } from "next/navigation";
import prisma from "@/app/lib/prisma";
  • Now find this line inside the function:
    throw new Error("Bookmark not found"); // No error boundary yet
  • Replace that entire line with:
    notFound();

What does notFound() do?

The notFound() function from next/navigation stops rendering the current page and shows the nearest not-found.tsx boundary instead. It is a clean way to handle missing resources without throwing a raw error.

  • Save app/bookmarks/[id]/page.tsx.
  • Now create the not-found.tsx boundary by right-clicking the [id] folder and selecting New File.
  • Name the file not-found.tsx and paste the following code:
import Link from "next/link";

export default function NotFound() {
  return (
    // Friendly 404 message with a link back
    <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
      <h2 className="text-2xl font-bold">Bookmark Not Found</h2>
      <p className="text-gray-500">
        The bookmark you are looking for does not exist.
      </p>
      <Link
        href="/bookmarks"
        className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
      >
        Back to Bookmarks
      </Link>
    </div>
  );
}

What does this file do?

When notFound() is called in page.tsx, Next.js looks for the nearest not-found.tsx in the same folder (or above) and renders it instead of the page. The learner sees a styled message instead of a crash.

  • Save app/bookmarks/[id]/not-found.tsx.
  • In your browser, visit http://localhost:3000/bookmarks/999 again.

Instead of the ugly crash, you now see a centered "Bookmark Not Found" message with a blue button linking back to the bookmarks list.

Still seeing the error page?

  • Make sure you replaced throw new Error("Bookmark not found") with notFound() in page.tsx. If the throw is still there, the not-found boundary never activates.
  • Confirm that not-found.tsx is inside the [id] folder, not in the bookmarks folder directly.
  • help me debug my not-found page

✔️ Awesome, I've got everything!

Great. Make sure both page.tsx and not-found.tsx are saved in the [id] folder.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/bookmarks/[id]/page.tsx file has these pieces in order:

  • Three imports: Link from next/link, notFound from next/navigation, and prisma from @/app/lib/prisma.
  • An async BookmarkDetailPage function that calls await params to get the id.
  • A prisma.bookmark.findUnique() call using parseInt(id).
  • An if (!bookmark) check that calls notFound() (not throw new Error).
  • A return block with the back link, title, URL, and created date.

Your app/bookmarks/[id]/not-found.tsx should match the code block you pasted above.

The not-found boundary handles missing bookmarks cleanly. But what about unexpected runtime errors, like a database connection failure? For those, you need error.tsx.

  • Right-click the app/bookmarks folder (not the [id] subfolder) and select New File.
  • Name the file error.tsx and paste the following code:
"use client";

import { useEffect } from "react";

export default function Error({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  // Log the error for debugging
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
      <h2 className="text-2xl font-bold">Something went wrong!</h2>
      <p className="text-gray-500">{error.message}</p>
      <button
        onClick={() => retry()}
        className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  );
}

What does error.tsx do?

The error.tsx file must be a Client Component ('use client'). Next.js wraps the route segment in a React error boundary automatically.

  • error receives the thrown error object, so you can display its message.
  • retry is a function that re-renders the segment, giving the user a one-click recovery.
  • Placing error.tsx in app/bookmarks/ catches errors from any page inside the bookmarks segment, including the detail page.
  • Save app/bookmarks/error.tsx.
  • To see this error boundary in action, visit http://localhost:3000/bookmarks/abc in your browser.

The URL contains abc instead of a number. parseInt("abc") returns NaN, which causes Prisma to throw a runtime error. Your error.tsx boundary catches it and displays "Something went wrong!" with a Try again button.

Not seeing the error boundary?

  • Confirm error.tsx is directly inside app/bookmarks/, not inside the [id] subfolder.
  • Check that "use client" is the very first line in the file. Without it, Next.js cannot use the component as an error boundary.
  • help me debug my error.tsx boundary

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the bookmarks directory.

ⓧ I'd like to double check the full code

Your app/bookmarks/error.tsx should match the code block you pasted above.

Add a Route Handler for the bookmarks API

A Route Handler lets you create API endpoints directly inside the app directory. Instead of returning HTML, it returns raw data like JSON.

  • In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder api.
  • Right-click the api folder and create another folder named bookmarks.
  • Inside app/api/bookmarks, create a new file named route.ts and paste the following code:
import { NextResponse } from "next/server";
import prisma from "@/app/lib/prisma";

// Handle GET requests to /api/bookmarks
export async function GET() {
  const bookmarks = await prisma.bookmark.findMany({
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(bookmarks);
}

What does this code do?

Exporting a function named GET from a route.ts file tells Next.js to handle HTTP GET requests at that path. The function queries all bookmarks from the database and returns them as a JSON response using NextResponse.json().

This is how you build API endpoints in the App Router. The file name must be route.ts (not page.tsx), and the two cannot coexist in the same folder.

  • Save app/api/bookmarks/route.ts.
  • In your browser, visit http://localhost:3000/api/bookmarks.

You should see a JSON array containing all your bookmarks, with each object showing id, title, url, and createdAt fields.

Seeing a 404 at /api/bookmarks?

  • Check your folder structure: the file must be at app/api/bookmarks/route.ts. Make sure there is no typo in the folder names.
  • The file must be named route.ts (not route.tsx or page.ts). Route Handlers use .ts because they return data, not JSX.
  • help me debug my Route Handler

✔️ Awesome, I've got everything!

Great. Make sure route.ts is saved in the app/api/bookmarks folder.

ⓧ I'd like to double check the full code

Your app/api/bookmarks/route.ts should match the code block you pasted above.

Your bookmarks app now has individual detail pages, graceful error handling for missing and broken pages, and a JSON API. Next up, you will lock the whole thing down with authentication so only logged-in users can access the dashboard.

Add Authentication with Cookies and Middleware

Your bookmarks app has full CRUD, a detail page, error boundaries, and a JSON API. There is one major gap though.

Anyone can access your bookmarks right now. There is no login, no session, and no way to keep anonymous visitors out. In this step, you will add a login page with a Server Action that sets a session cookie, and a proxy.ts file that intercepts every request and redirects unauthenticated users to the login page.

In this step, get ready to:
  • Create a login page and auth Server Actions for login and logout.
  • Create proxy.ts to protect bookmarks routes from unauthenticated access.
  • Add a logout button and verify the full authentication flow.
Create the login Server Action

Before you can build a login form, you need a Server Action that checks credentials and sets a session cookie. The cookies() API from next/headers lets Server Actions read and write cookies on the response.

  • Create a new file at app/lib/auth.ts and paste the following code:
"use server";

import { cookies } from "next/headers";
import { redirect } from "next/navigation";

// Server Action for login - receives previous state and form data
export async function login(prevState: string | undefined, formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // Check hardcoded demo credentials
  if (email === "user@demo.com" && password === "password") {
    // Set an httpOnly session cookie (await required in Next.js 16)
    const cookieStore = await cookies();
    cookieStore.set("session", "authenticated", {
      httpOnly: true,
      secure: false,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24,
    });
    redirect("/bookmarks");
  }

  // Return error message on failure
  return "Invalid email or password";
}

What does this code do?

The "use server" directive marks every export in this file as a Server Action. The login function takes a prevState parameter (used by useActionState on the client) and a FormData object containing the submitted fields.

When credentials match, await cookies() accesses the cookie store and sets an httpOnly cookie named session. The httpOnly flag means JavaScript in the browser cannot read or tamper with this cookie. Then redirect("/bookmarks") sends the user to the dashboard.

Why await cookies()?

Next.js 16 requires async access to cookies(), headers(), and draftMode(). Calling cookies() without await will throw an error.

  • Save app/lib/auth.ts.
Build the login form as a Client Component

The login form needs interactivity to show error messages and a loading state. That makes it a Client Component. You will use React's useActionState hook to wire the form to the login Server Action.

  • Create a new file at app/login/page.tsx and paste the following code:
"use client";

import { useActionState } from "react";
import { login } from "@/app/lib/auth";

export default function LoginPage() {
  // useActionState returns error state, a wrapped action, and a pending flag
  const [errorMessage, formAction, pending] = useActionState(login, undefined);

  return (
    <div className="flex items-center justify-center min-h-[80vh]">
      <form action={formAction} className="space-y-4 w-full max-w-sm">
        <h2 className="text-2xl font-bold text-center">Login</h2>
        {/* Email field */}
        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            Email
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            className="w-full border rounded-lg px-3 py-2"
          />
        </div>
      </form>
    </div>
  );
}

What is useActionState?

The useActionState hook wraps a Server Action and returns three values: the last result (errorMessage), a wrapped formAction you pass to the form's action prop, and a pending boolean that is true while the action runs.

Passing formAction to the form's action prop ensures React runs the action inside a transition, which keeps the UI responsive and updates pending automatically.

  • Save app/login/page.tsx.
  • Switch to your browser and visit http://localhost:3000/login.
  • You should see a centered form with a "Login" heading and an email field.

Don't see the login page?

  • Make sure the file is at app/login/page.tsx (inside the app folder, not the project root).
  • Check that your dev server is still running in the terminal.
  • Help me debug this

The form is missing a password field, a submit button, and error handling. Time to add those.

  • In app/login/page.tsx, find the closing </div> of the email field (just above </form>). Add the following code between that </div> and the </form> tag:
        {/* Password field */}
        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            id="password"
            name="password"
            type="password"
            required
            className="w-full border rounded-lg px-3 py-2"
          />
        </div>
        {/* Show error message from the Server Action */}
        {errorMessage && (
          <p className="text-red-500 text-sm">{errorMessage}</p>
        )}
        {/* Submit button with loading state */}
        <button
          type="submit"
          disabled={pending}
          className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
        >
          {pending ? "Logging in..." : "Login"}
        </button>
        <p className="text-xs text-gray-400 text-center">
          Demo credentials: user@demo.com / password
        </p>

What does this code do?

  • The password input uses type="password" to mask the entered text.
  • The {errorMessage && ...} block conditionally renders the error string returned by the login Server Action when credentials are wrong.
  • The disabled={pending} attribute greys out the button while the action is running, and the label swaps to "Logging in..." as visual feedback.
  • Save app/login/page.tsx.
  • Refresh http://localhost:3000/login in your browser. You should now see the full login form with email, password, a blue Login button, and demo credentials hint text.
  • Enter user@demo.com as the email and password as the password.
  • Click Login.
  • You should be redirected to /bookmarks and see your bookmarks dashboard.

Login button not working?

  • Double-check that the "use client" directive is on the very first line of app/login/page.tsx.
  • Make sure the new code was placed between the email field's closing </div> and </form>. If it is outside the <form> tag, the button will not trigger the action.
  • Verify you typed the credentials exactly: user@demo.com and password.
  • Help me debug my login form

Now try entering wrong credentials. Type anything other than the demo credentials and click Login. You should see "Invalid email or password" appear in red below the password field.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/login/page.tsx.

ⓧ I'd like to double check the full code

The full app/login/page.tsx is built from two chunks. Scroll up and confirm:

  • Chunk 1 has the "use client" directive, the useActionState import, the component function, and the email field inside a <form>.
  • Chunk 2 sits between the email field's closing </div> and </form>. It contains the password field, error message rendering, submit button, and demo credentials hint.
Create proxy.ts to protect bookmarks routes

Your login works, but there is still a problem. Open a new incognito or private browser window and visit http://localhost:3000/bookmarks. The bookmarks page loads with no login required.

Next.js 16 solves this with a proxy.ts file at the project root. This file runs code before every matched request reaches your app. It is the successor to the older middleware.ts convention, renamed to better reflect its role as a network boundary.

  • Create a new file called proxy.ts at the project root (the same level as your app folder, not inside it) and paste the following code:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// Runs before every matched request
export function proxy(request: NextRequest) {
  const session = request.cookies.get("session")?.value;
  const { pathname } = request.nextUrl;

  // Redirect unauthenticated users away from /bookmarks
  if (pathname.startsWith("/bookmarks") && !session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // Redirect authenticated users away from /login
  if (pathname === "/login" && session) {
    return NextResponse.redirect(new URL("/bookmarks", request.url));
  }

  // Allow all other requests to proceed
  return NextResponse.next();
}

// Only run the proxy on paths that are not static assets
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

What does this code do?

  • The exported proxy function intercepts every request matching the matcher pattern before it reaches your app.
  • It reads the session cookie from the request. If someone visits a /bookmarks route without a session cookie, they get redirected to /login.
  • The reverse check redirects already-logged-in users away from /login back to /bookmarks.
  • The matcher excludes API routes and static assets so those requests pass through without being intercepted.

Why proxy.ts instead of middleware.ts?

Next.js 16 renamed middleware.ts to proxy.ts. The term "proxy" better describes what the file does: it sits in front of your app as a network boundary, intercepting requests before they reach any page or API route. The functionality is identical. The exported function must be named proxy (not middleware).

  • Save proxy.ts.
  • Return to your incognito or private browser window.
  • Visit http://localhost:3000/bookmarks.
  • You should be automatically redirected to /login. The bookmarks page is now protected.

Still seeing bookmarks without a redirect?

  • Confirm proxy.ts is at the project root (next to the app folder), not inside the app folder.
  • Make sure the exported function is named proxy. Using middleware as the name will not work in Next.js 16.
  • Try stopping and restarting your dev server. Proxy file changes sometimes require a restart.
  • If you are not using an incognito window, your browser may have a session cookie from the login you tested earlier. Open a fresh incognito window to test without any cookies.
  • Help me debug my proxy.ts

✔️ Awesome, I've got everything!

Great. Make sure you have saved proxy.ts.

ⓧ I'd like to double check the full code

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const session = request.cookies.get("session")?.value;
  const { pathname } = request.nextUrl;

  if (pathname.startsWith("/bookmarks") && !session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  if (pathname === "/login" && session) {
    return NextResponse.redirect(new URL("/bookmarks", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Add a logout button and verify the full flow

You can log in and get redirected, but there is no way to log out yet. You need a logout Server Action that deletes the session cookie and a button in the bookmarks sidebar to trigger it.

  • In app/lib/auth.ts, scroll to the bottom of the file (after the closing } of the login function).
  • Add the following logout function below it:
// Delete the session cookie and redirect to the login page
export async function logout() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  redirect("/login");
}

What does this code do?

The logout function uses the same await cookies() pattern to access the cookie store, deletes the session cookie, and redirects to /login. Because it is in a "use server" file, it can be called directly from a form in a Server Component.

  • Save app/lib/auth.ts.

Now wire the logout button into the bookmarks sidebar.

  • In app/bookmarks/layout.tsx, find this import line at the top:
import Link from "next/link";
  • Add the logout import directly below it so the imports look like this:
import Link from "next/link";
import { logout } from "@/app/lib/auth";
  • Inside the same file, find the Add New link inside the <nav>:
          <Link href="/bookmarks/add" className="text-blue-600 hover:underline">
            Add New
          </Link>
  • Add the logout form directly below that Link (before the closing </nav> tag):
          <form action={logout}>
            <button
              type="submit"
              className="text-red-600 hover:underline text-left"
            >
              Logout
            </button>
          </form>

Why a form instead of an onClick handler?

The bookmarks layout is a Server Component (no "use client" directive). Server Components cannot have event handlers. Wrapping the button in a <form> with action={logout} calls the Server Action directly when the form submits. No client-side JavaScript is needed.

  • Save app/bookmarks/layout.tsx.

Time to test the complete authentication flow from start to finish.

Before you run through the test, what do you think will happen if you visit /bookmarks in a fresh incognito window without logging in first?

  • Open a new incognito or private browser window.
  • Visit http://localhost:3000/bookmarks. You should be redirected to /login.
  • Enter user@demo.com as the email and password as the password.
  • Click Login. You should be redirected to /bookmarks and see your bookmarks with the sidebar navigation.
  • Click Logout in the sidebar. You should be redirected to /login.
  • Try visiting http://localhost:3000/bookmarks again. You should be redirected back to /login because the session cookie was deleted.

Logout not redirecting?

  • Check that the logout import in app/bookmarks/layout.tsx matches exactly: import { logout } from "@/app/lib/auth".
  • Make sure the <form action={logout}> wraps the Logout button. If the button is outside the form, clicking it will not trigger the action.
  • Help me debug my logout button

✔️ Awesome, I've got everything!

Great. Make sure you have saved both app/lib/auth.ts and app/bookmarks/layout.tsx.

ⓧ I'd like to double check the full code

Here is the complete app/lib/auth.ts:

"use server";

import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export async function login(prevState: string | undefined, formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (email === "user@demo.com" && password === "password") {
    const cookieStore = await cookies();
    cookieStore.set("session", "authenticated", {
      httpOnly: true,
      secure: false,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24,
    });
    redirect("/bookmarks");
  }

  return "Invalid email or password";
}

export async function logout() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  redirect("/login");
}

Here is the complete app/bookmarks/layout.tsx:

import Link from "next/link";
import { logout } from "@/app/lib/auth";

export default function BookmarksLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex min-h-[calc(100vh-64px)]">
      <aside className="w-64 bg-gray-100 p-6 border-r">
        <nav className="flex flex-col gap-4">
          <Link href="/bookmarks" className="text-blue-600 hover:underline">
            All Bookmarks
          </Link>
          <Link href="/bookmarks/add" className="text-blue-600 hover:underline">
            Add New
          </Link>
          <form action={logout}>
            <button
              type="submit"
              className="text-red-600 hover:underline text-left"
            >
              Logout
            </button>
          </form>
        </nav>
      </aside>
      <section className="flex-1 p-8">{children}</section>
    </div>
  );
}

Your bookmarks app now has a complete authentication layer. The proxy.ts file guards every bookmarks route, the login Server Action sets a secure cookie, and the logout action tears it down. Next up, you will tackle the Secret Mission and add bookmark categories with a filterable dropdown.

Secret mission

Add Bookmark Categories with Filtering

Your bookmarks are a flat list with no organization. Can you extend the database schema, update the Server Actions, and build a Client Component dropdown that filters bookmarks by category? This proves you can evolve a Next.js app end-to-end.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with SQLite, so there are no ongoing cloud costs or paid services to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The bookmarks-app project folder on your Desktop (contains all source code, node_modules, and the SQLite database).
  • The Next.js dev server process running at http://localhost:3000.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your dev server stays running at http://localhost:3000.
  • Your SQLite database (dev.db) keeps all your bookmarks intact.
  • You can restart the server any time by running npm run dev from inside the bookmarks-app folder.

Pause - I'll come back to this later

Stop the dev server to free up your terminal and the port, but keep all project files so you can pick up where you left off.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • Your project files and database stay on your Desktop. When you are ready to resume, open a terminal in the bookmarks-app folder and run npm run dev.

Delete - I don't want to use this again

Remove all project files, the database, and generated code. This frees up disk space and cleans everything this project created.

  • Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
  • Navigate to your Desktop by running:
cd ~/Desktop
  • Delete the entire project folder by running:

macOS

rm -rf bookmarks-app

Windows

rmdir /s /q bookmarks-app
  • Confirm the folder is gone by running:
ls

You should no longer see bookmarks-app in the output.

Nice Work!

Nice Work!

Nice work! You just built a full-stack bookmarks manager with Next.js 16, Prisma, and SQLite. You can log in, save bookmarks, view details, hit a JSON API, and see graceful error handling in action.

You've learned how to:

  • Build a database-backed app using file-system routing, nested layouts, and Server Components that fetch directly from Prisma, with a Route Handler serving your bookmarks as a JSON API.
  • Create interactive forms as Client Components powered by Server Actions for add, delete, and login mutations, with revalidatePath refreshing data after every change.
  • Handle errors gracefully with error.tsx and not-found.tsx boundaries, and protect routes with cookie-based auth in proxy.ts.
  • Secret Mission: Extended the app with bookmark categories and a filterable dropdown that combines Server Component data with client-side state.

Ready to quiz yourself?