All Tutorials
nextjs
typescript
server-actions
app-router
sqlite
fullstack

Build a Bookmark Manager with Next.js, TypeScript, and Server Actions

A code-along tutorial: build a bookmark manager with Next.js, TypeScript, and Server Actions, backed by SQLite. Add, delete, tag, and search links with no separate API layer, using Server Actions for writes and a Server Component for reads. Every file is here to paste and run.

15 min read2026-08-17

The short answer

Yes, you can build a full-stack app in Next.js with no separate API. Scaffold a Next.js App Router project, store data in SQLite with better-sqlite3, and write to it with Server Actions: async functions marked "use server" that you pass straight to a form's action, so there is no fetch call and no /api route to hand-roll for the mutations. The page is a Server Component that queries SQLite directly at render time and lists your bookmarks, and a small "use client" search box updates the URL so the server re-queries and returns the filtered list. This tutorial builds a bookmark manager (add, delete, tag, and search) end to end, and every file is on this page to paste and run.

  • Setup time: about 10 minutes
  • Stack: Next.js 15 (App Router), TypeScript, Server Actions, SQLite
  • Runtime dependency: one (better-sqlite3); Next.js brings the rest
  • What you build: add, delete, tag, and search bookmarks, with no separate API
  • Every file is on this page: paste it into a fresh create-next-app project and run

What you'll build

By the end of this tutorial you'll have a working bookmark manager running in your browser: a form to save a link with a title and tags, a list of your saved bookmarks newest-first, a delete button on each one, a search box that filters the list, and clickable tags that filter by topic. It's a real full-stack app, with data saved to a SQLite file on disk that survives restarts, and it has no separate backend or API server.

Every file you need is on this page. Paste each one into a fresh create-next-app project, follow the setup steps, and it runs. The point that makes this worth building over another to-do list is the writes: instead of hand-rolling a POST /api/bookmarks route and a fetch call to hit it, you use Server Actions, which are the Next.js way to run server code straight from a form. The tutorial explains that shortcut, so you leave knowing when to reach for a Server Action and when you still want a real API route.

Server Actions vs an API route: the shortcut this uses

In a classic React app, saving a bookmark takes two halves. You write a backend endpoint (say POST /api/bookmarks) that inserts the row, and you write client code that calls fetch("/api/bookmarks", { method: "POST", body: ... }), waits for the response, and updates the UI. You maintain both halves, keep their types in sync by hand, and manage the loading and error states of the fetch.

A Server Action collapses that. You write one async function, mark it with "use server", and pass it directly to a form's action. When the form submits, Next.js runs that function on the server with the form's data, then re-renders the page. There is no fetch, no /api route, and no client-side JSON wiring for the mutation. The function runs where your database lives, so it can call SQLite directly.

This is not magic and it is not always the right tool. Server Actions are perfect for the writes inside your own app (a form saving a bookmark, a button deleting one). A dedicated API route still wins when something outside your app needs the endpoint: a mobile client, a webhook, a public API, or a third party. This tutorial uses Server Actions for the mutations because everything writing to the data is a form on the same page, which is exactly the case they were built for.

Setup

Scaffold a fresh Next.js app with the official tool. It asks a short list of questions; the answers that match this tutorial's file paths are below the command.

bash
npx create-next-app@latest bookmarks

Answer the prompts like this: TypeScript? Yes. ESLint? Yes. App Router? Yes. `src/` directory? No. The import alias can stay the default @/*. Tailwind is your call; this tutorial uses plain inline styles so it runs with or without it. Saying No to the src/ directory matters, because the files below live at app/ and lib/ in the project root. Then move into the project and add the SQLite driver:

bash
cd bookmarks
npm install better-sqlite3
npm install -D @types/better-sqlite3

better-sqlite3 is a popular, well-tested SQLite driver for Node. It's synchronous, which suits this app perfectly: a Server Component can query it and render the result in one pass, with no await on the database and no loading spinner. It's the same driver used in our URL shortener tutorial, so the query patterns will look familiar if you've done that one.

Tell Next.js that better-sqlite3 is a server package

One gotcha to handle before writing any code. better-sqlite3 is a native module (it ships a compiled binary), and Next.js will try to bundle it like normal JavaScript, which fails. The fix is one line telling Next.js to leave it alone and load it from node_modules at runtime. Open next.config.ts (created for you by the scaffold) and replace it with this:

typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // better-sqlite3 is a native module. Keep it out of the bundle so Next
  // loads it from node_modules at runtime instead of trying to pack it.
  serverExternalPackages: ["better-sqlite3"],
};

export default nextConfig;

Skip this and the first page load throws an error about a missing or invalid binding. It's the one piece of plumbing native database drivers need in Next.js, and it's easy to forget, so do it now.

The database layer

Put all the storage code in one file so the rest of the app deals in typed function calls, never raw SQL. This module opens the SQLite file, creates the bookmarks table on first run, and exports helpers to read, filter, insert, and delete. Create lib/db.ts:

typescript
import Database from "better-sqlite3";

// One row of the bookmarks table. Written once, checked everywhere it's used.
export interface Bookmark {
  id: number;
  url: string;
  title: string;
  tags: string;
  created_at: string;
}

// Next.js reloads modules on every save in dev, which would open a new SQLite
// connection each time and leave a pile of open handles. Stash the connection
// on globalThis so hot reloads reuse the same one.
const globalForDb = globalThis as unknown as { bookmarkDb?: Database.Database };

function getDb(): Database.Database {
  if (globalForDb.bookmarkDb) return globalForDb.bookmarkDb;

  const db = new Database("bookmarks.db");
  db.pragma("journal_mode = WAL");
  db.exec(`
    CREATE TABLE IF NOT EXISTS bookmarks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      url TEXT NOT NULL,
      title TEXT NOT NULL,
      tags TEXT NOT NULL DEFAULT '',
      created_at TEXT NOT NULL
    )
  `);

  globalForDb.bookmarkDb = db;
  return db;
}

// Read bookmarks, optionally filtered by a search term and/or a tag. Both
// filters bind values with ? placeholders, so a user's input can never be
// spliced into the SQL string.
export function getBookmarks(
  filter: { query?: string; tag?: string } = {},
): Bookmark[] {
  const db = getDb();
  const clauses: string[] = [];
  const params: string[] = [];

  if (filter.query) {
    clauses.push("(title LIKE ? OR url LIKE ? OR tags LIKE ?)");
    const like = "%" + filter.query + "%";
    params.push(like, like, like);
  }
  if (filter.tag) {
    clauses.push("tags LIKE ?");
    params.push("%" + filter.tag + "%");
  }

  const where = clauses.length ? "WHERE " + clauses.join(" AND ") : "";
  const sql = "SELECT * FROM bookmarks " + where + " ORDER BY created_at DESC";
  return db.prepare(sql).all(...params) as Bookmark[];
}

// The distinct tags across every bookmark, for the filter row.
export function getAllTags(): string[] {
  const db = getDb();
  const rows = db
    .prepare("SELECT tags FROM bookmarks WHERE tags <> ''")
    .all() as { tags: string }[];
  const set = new Set<string>();
  for (const row of rows) {
    for (const tag of row.tags.split(",")) {
      const trimmed = tag.trim();
      if (trimmed) set.add(trimmed);
    }
  }
  return [...set].sort();
}

export function createBookmark(input: {
  url: string;
  title: string;
  tags: string;
}): void {
  const db = getDb();
  db.prepare(
    "INSERT INTO bookmarks (url, title, tags, created_at) VALUES (?, ?, ?, ?)",
  ).run(input.url, input.title, input.tags, new Date().toISOString());
}

export function deleteBookmark(id: number): void {
  const db = getDb();
  db.prepare("DELETE FROM bookmarks WHERE id = ?").run(id);
}

Three decisions worth calling out.

The connection is cached on `globalThis`. In dev, Next.js re-evaluates your modules on every file save. Without the cache, each reload runs new Database(...) again and you leak connections until SQLite complains. Stashing the handle on globalThis is the same trick the Prisma docs recommend, and it keeps you to exactly one connection.

Every value is bound with a `?` placeholder. The search term and tag come from a user, so they never get concatenated into the SQL. better-sqlite3 binds them safely, which closes the door on SQL injection. Notice that the query text itself is built from a fixed list of clauses, not from user input.

Tags are a plain comma-separated string. For a small app this is fine and keeps the schema to one table. The honest limitation: filtering with tags LIKE '%go%' will also match a golang tag, and there's no separate tags table to enforce a clean set. If you grow this into something real, a tags table with a join is the upgrade. For learning the Next.js data flow, one column is the right amount of complexity.

Server Actions for writes

Now the writes. Create app/actions.ts. The "use server" at the very top of the file marks everything it exports as a Server Action: these functions only ever run on the server, and Next.js wires them up so a form can call them directly.

typescript
"use server";

import { revalidatePath } from "next/cache";
import { createBookmark, deleteBookmark as removeBookmark } from "@/lib/db";

export async function addBookmark(formData: FormData): Promise<void> {
  const rawUrl = String(formData.get("url") ?? "").trim();
  const title = String(formData.get("title") ?? "").trim();
  const tags = String(formData.get("tags") ?? "").trim();

  if (!rawUrl) return;

  // Add a scheme if the user left it off, so the saved link actually opens.
  const hasScheme =
    rawUrl.startsWith("http://") || rawUrl.startsWith("https://");
  const url = hasScheme ? rawUrl : "https://" + rawUrl;

  createBookmark({ url, title: title || url, tags });

  // Tell Next.js the home page's data changed, so it re-renders the list.
  revalidatePath("/");
}

export async function deleteBookmark(formData: FormData): Promise<void> {
  const id = Number(formData.get("id"));
  if (Number.isInteger(id)) removeBookmark(id);
  revalidatePath("/");
}

Read what each piece does.

The argument is `FormData`. When you pass a Server Action to a <form action={...}>, Next.js calls it with the submitted form's FormData. You pull fields out with formData.get("name"), which is why the inputs in the page below have name attributes. No JSON, no request body parsing.

Validation happens on the server. addBookmark ignores an empty URL and adds https:// when the scheme is missing, so a link like example.com still opens. This runs on the server where you can trust it, not in the browser where a user could skip it.

`revalidatePath("/")` is the refresh. After the insert or delete, the home page's cached render is stale. revalidatePath tells Next.js to throw it out and render the list again, so the new bookmark appears (or the deleted one vanishes) with no client-side state to manage. That one line is doing the job your old fetch-then-setState dance used to do.

Go deeper on React and Next.js, free

This tutorial assumes you're comfortable with React components. If you want the full path into React and modern frontend work, our best free React courses for 2026 ranks the strongest free options, and /learn/frontend sequences them into a route from zero to job-ready.

The search box: a Client Component

The list is rendered on the server, but the search box needs to respond to typing, which is a browser job. This is the App Router's client/server split in one small file: the input is a Client Component (it uses React hooks and an event handler), and all it does is push the search term into the URL. The server reads that URL and returns the filtered list. Create app/search-bar.tsx:

typescript
"use client";

import { useSearchParams, usePathname, useRouter } from "next/navigation";

export function SearchBar() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const router = useRouter();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams.toString());
    if (term) {
      params.set("q", term);
    } else {
      params.delete("q");
    }
    // Update the URL without a full reload. The server re-renders the list
    // for the new ?q=... value.
    router.replace(pathname + "?" + params.toString());
  }

  return (
    <input
      type="search"
      placeholder="Search bookmarks by title, URL, or tag"
      defaultValue={searchParams.get("q") ?? ""}
      onChange={(event) => handleSearch(event.target.value)}
      style={{ width: "100%", padding: "0.5rem", marginBottom: "1rem" }}
    />
  );
}

The key idea: this component holds no list and does no filtering itself. It writes ?q=react into the URL, and router.replace swaps the URL without a hard reload. Because the page reads the query string on the server, that change makes the server re-run getBookmarks and send back the filtered list. State lives in the URL, which means a search is shareable and survives a refresh, and the filtering stays in SQL where it belongs.

One refinement for later: this fires on every keystroke. On a real app you'd debounce it (wait until the user pauses) with a small helper so you're not updating the URL on every letter. For a local SQLite app it's plenty fast as is.

Wire it together: the page

The home page ties everything together. It's a Server Component, so it can call getBookmarks and getAllTags directly, with no fetch and no API route in between. It reads the search term and tag from the URL, renders the add form (pointed at the addBookmark Server Action), the search box, a row of tag filters, and the list (each item with a delete form). Replace app/page.tsx with this:

typescript
import Link from "next/link";
import { getBookmarks, getAllTags } from "@/lib/db";
import { addBookmark, deleteBookmark } from "./actions";
import { SearchBar } from "./search-bar";

// The list changes as you add and delete, so never cache this render.
export const dynamic = "force-dynamic";

type Props = {
  searchParams: Promise<{ q?: string; tag?: string }>;
};

export default async function Home({ searchParams }: Props) {
  // In Next.js 15 searchParams is a Promise, so await it.
  const { q, tag } = await searchParams;
  const bookmarks = getBookmarks({ query: q, tag });
  const tags = getAllTags();

  return (
    <main
      style={{
        maxWidth: 640,
        margin: "0 auto",
        padding: "2rem 1rem",
        fontFamily: "system-ui, sans-serif",
      }}
    >
      <h1>Bookmarks</h1>

      <form
        action={addBookmark}
        style={{ display: "grid", gap: "0.5rem", margin: "1rem 0 1.5rem" }}
      >
        <input name="url" type="text" placeholder="https://example.com" required />
        <input name="title" type="text" placeholder="Title (optional)" />
        <input name="tags" type="text" placeholder="tags, comma, separated" />
        <button type="submit">Add bookmark</button>
      </form>

      <SearchBar />

      {tags.length > 0 && (
        <p style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
          <Link href="/">All</Link>
          {tags.map((t) => (
            <Link key={t} href={"/?tag=" + encodeURIComponent(t)}>
              #{t}
            </Link>
          ))}
        </p>
      )}

      <ul style={{ listStyle: "none", padding: 0, display: "grid", gap: "0.75rem" }}>
        {bookmarks.map((bookmark) => (
          <li
            key={bookmark.id}
            style={{ border: "1px solid #ddd", borderRadius: 8, padding: "0.75rem" }}
          >
            <a href={bookmark.url} target="_blank" rel="noopener noreferrer">
              <strong>{bookmark.title}</strong>
            </a>
            <div style={{ color: "#666", fontSize: "0.85rem" }}>{bookmark.url}</div>
            {bookmark.tags && (
              <div style={{ fontSize: "0.8rem", color: "#444" }}>{bookmark.tags}</div>
            )}
            <form action={deleteBookmark} style={{ marginTop: "0.5rem" }}>
              <input type="hidden" name="id" value={bookmark.id} />
              <button type="submit">Delete</button>
            </form>
          </li>
        ))}
        {bookmarks.length === 0 && (
          <li>No bookmarks yet. Add one with the form above.</li>
        )}
      </ul>
    </main>
  );
}

Notice what is not here: no useState for the list, no useEffect to load data, no fetch. The Server Component reads from SQLite at render time and hands back finished HTML. The add and delete forms point straight at Server Actions. The tag links are plain <Link>s that set ?tag=..., which the same server render reads and filters on. The only Client Component in the whole app is the search box, and only because typing is a browser event. That's the App Router mental model in one screen: server by default, client only where you need interactivity.

One Next.js 15 detail: searchParams is a Promise now, so the page awaits it before reading q and tag. Awaiting it also marks the route as dynamic, which is what you want here since the list depends on the request.

Run it

Start the dev server:

bash
npm run dev

Open http://localhost:3000 and try the app:

  • Add a bookmark: paste a URL, give it a title and a couple of tags like react, docs, and submit. It appears at the top of the list, and a bookmarks.db file shows up in your project root.
  • Restart the server (Ctrl+C, then npm run dev again) and reload. Your bookmarks are still there, because they live in the SQLite file, not in memory.
  • Type in the search box. The list narrows as the server re-queries for each ?q= value, and the term stays in the URL so you can share or refresh it.
  • Click a tag under the search box. The list filters to that tag; click All to clear it.
  • Hit Delete on a bookmark. The Server Action removes the row and revalidatePath re-renders the list without it, no page reload of your own to write.

Add bookmarks.db (and bookmarks.db-shm, bookmarks.db-wal) to your .gitignore so your local data doesn't end up in version control.

Where to go next

Be clear about what this covered and what it didn't. You built a full-stack Next.js app with the App Router's core data flow: a Server Component reads the database directly, Server Actions handle the writes with no API route, and a single Client Component adds the one bit of interactivity that needs the browser. That pattern scales up to most CRUD features you'll build in Next.js.

The honest limitation is deployment. SQLite writes to a local file, which is great on your machine but a poor fit for serverless hosts like Vercel, where the filesystem is read-only and not shared between instances. So the one thing to change before shipping is the database. Deploy on Vercel: swap SQLite for a hosted Postgres (Neon and Supabase both have free tiers), or a hosted SQLite like Turso; only lib/db.ts changes, because the rest of the app only ever calls its helpers. Add auth so bookmarks are per-user, with a library like Auth.js. Add editing by writing an updateBookmark Server Action next to the two you already have.

For the sequenced route into React and frontend work that leads here, see /learn/frontend, and for the ranked free courses, our best free React courses guide.

Frequently asked questions

Do I need a separate backend for a Next.js app?

Not for an app like this. Next.js runs server code as part of the same project, so a Server Component can read your database directly at render time and a Server Action can write to it straight from a form. That covers the reads and writes of a typical CRUD app with no separate API server and no fetch calls of your own. You add a real backend or dedicated API routes when something outside your app needs an endpoint, such as a mobile client, a public API, or a webhook.

What's the difference between a Server Action and an API route?

A Server Action is an async function marked "use server" that you pass directly to a form's action; Next.js runs it on the server with the form data and re-renders the page, with no client fetch and no URL to design. An API route is an HTTP endpoint (a route handler at app/api/.../route.ts) that anything can call over the network. Use a Server Action for writes that happen inside your own app's UI, and an API route when an external client or service needs to hit the endpoint. This tutorial uses Server Actions because every write is a form on the same page.

Is SQLite OK for a real Next.js app?

Yes, on the right host. SQLite is fast, reliable, and needs no separate database server, which makes it excellent for local development, prototypes, and apps deployed on a host with a persistent disk (a VPS, Fly.io, a container with a volume). Where it struggles is serverless platforms like Vercel, whose filesystem is read-only and not shared across instances, so writes don't persist. For those, keep this exact app structure and swap the driver in lib/db.ts for a hosted Postgres or a hosted SQLite service like Turso.

Can I use this pattern with Postgres instead?

Yes, and that's the intended upgrade path. The whole app talks to the database only through the helper functions in lib/db.ts (getBookmarks, createBookmark, deleteBookmark, getAllTags). Rewrite those to use a Postgres client such as the pg library, Drizzle, or Prisma, and nothing else has to change: the Server Actions, the Server Component, and the search box all stay the same because they never touch SQL directly. That's the payoff of keeping all the database code behind one module.

Why does better-sqlite3 need serverExternalPackages in next.config?

better-sqlite3 is a native module: it loads a compiled binary rather than plain JavaScript. Next.js bundles server code by default, and it can't bundle that binary, so the build or first request fails. Adding serverExternalPackages: ["better-sqlite3"] tells Next.js to leave the package alone and require it from node_modules at runtime, which is exactly what a native module needs. Most native database drivers need the same treatment, so it's a good line to remember.

Keep going on FreeCodingCourses