TL;DR: Build a personal bookmark manager with AI in under an hour. Paste a URL, give it a tag, and your link is saved forever — searchable, filterable, and described in plain English by AI. Stack: Next.js + SQLite + Prisma + Tailwind CSS. No cloud database account needed. The prompts below build everything — save links, tag them, search across titles and descriptions, and call the OpenAI API to auto-generate a summary for any URL.
What You're Building
A bookmark manager is the ideal second project after a notes app. The data model is simple (one main table), but you'll touch every important web development concept: forms, databases, search, external APIs, and deployment.
Here's what the finished app does:
- Save bookmarks — Paste a URL, add a title (or let AI fetch it), and hit save. Your link is stored in a local SQLite database.
- Add tags — Tag bookmarks with anything:
design,read-later,tools,inspiration. Filter your collection by tag in one click. - Search — Full-text search across titles, URLs, and descriptions. Type a word and find every bookmark that mentions it.
- AI-generated descriptions — Paste a URL and click "Describe." The app calls the OpenAI API, fetches the page content, and writes a two-sentence summary. You'll never look at a saved link and wonder "why did I save this?" again.
- Delete and archive — Remove links you no longer need or archive them so they stay searchable without cluttering your main view.
If you've already built a notes app with AI, this project will feel familiar — and slightly more interesting, because you'll add your first external API call. If this is your first project, don't worry: the prompts below do the heavy lifting.
The Tech Stack (and Why AI Chose It)
When you ask AI to build a personal bookmark manager, it tends to reach for the same stack. Here's what it picks and why each choice makes sense:
| Tool | What It Does | Why This One |
|---|---|---|
| Next.js 14 | React framework with built-in routing and API routes | Lets you build the UI and the backend in one project — no separate server to manage |
| SQLite + Prisma | Database + query builder | SQLite is a single file — zero setup, zero cost, zero network latency. Prisma makes queries readable |
| Tailwind CSS | Utility-class CSS framework | AI generates clean Tailwind layouts instantly. No separate stylesheet to manage |
| OpenAI API | AI text generation | Generates bookmark descriptions from page content with a single API call |
The SQLite choice is worth a moment. Most tutorials point beginners at cloud databases like Supabase or Firebase. That's not wrong — but for a personal tool, SQLite is genuinely better. It's a file on your machine. There's no dashboard to log into, no free tier limits to worry about, no connection strings to configure. You get all the power of a real SQL database with none of the overhead.
Prisma is an ORM — Object-Relational Mapper. It sits between your code and your database and lets you write queries that look like JavaScript instead of raw SQL. Instead of SELECT * FROM bookmarks WHERE tag = 'design', you write prisma.bookmark.findMany({ where: { tags: { has: 'design' } } }). AI generates Prisma queries naturally, and they're easy to read even if you've never written SQL before.
Step 1: Setting Up the Project
Start with one prompt. Copy this exactly into Claude, Cursor, or your AI tool of choice:
Set up a Next.js 14 project for a personal bookmark manager with these specs:
Tech stack: Next.js 14 with App Router, TypeScript, Tailwind CSS, SQLite database via Prisma ORM.
Initial setup:
- Initialize a Next.js 14 project with TypeScript and Tailwind CSS
- Install and configure Prisma with the SQLite provider
- Create a Prisma schema with a Bookmark model: id (Int, autoincrement), url (String), title (String), description (String, optional), tags (String — comma-separated), favicon (String, optional), archived (Boolean, default false), createdAt (DateTime)
- Run the initial migration to create the SQLite database file
- Create a lib/prisma.ts file that exports a singleton Prisma client
- Show me the exact commands to run to get everything installed and the database created
AI will output a schema file, the client setup, and a list of commands. Run them in order. When it's done, you'll have a dev.db file in your project — that's your entire database, sitting right there as a regular file.
Here's what the Prisma schema looks like after AI generates it:
// prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model Bookmark {
id Int @id @default(autoincrement())
url String
title String
description String?
tags String @default("")
favicon String?
archived Boolean @default(false)
createdAt DateTime @default(now())
}
What this does: Defines the shape of your data. Every bookmark has a URL, a title, an optional description, a comma-separated list of tags, an optional favicon URL, an archived flag, and a timestamp. The ? after a type means the field is optional — you don't have to provide a description when saving a bookmark.
After running npx prisma migrate dev --name init, Prisma creates the dev.db file and the bookmarks table inside it. You can inspect it anytime with npx prisma studio — a visual browser for your database that opens at localhost:5555.
Step 2: Building the Core Feature
The core feature is simple: save a bookmark, see your bookmarks. One form, one list. Here's the prompt:
Build the core bookmark saving and display feature:
- A form at the top of the page with fields for: URL (required), title (required), tags (optional, comma-separated). Include a "Save Bookmark" button.
- A REST API route at POST /api/bookmarks that validates the URL format, creates the bookmark using Prisma, and returns the saved bookmark as JSON.
- A GET /api/bookmarks route that returns all non-archived bookmarks sorted by createdAt descending.
- A bookmark card component that displays: the site favicon (using Google's favicon API: https://www.google.com/s2/favicons?domain=DOMAIN&sz=32), the title as a clickable link that opens in a new tab, the URL in smaller text, any tags as clickable pill badges, and the date saved.
- Display the bookmarks as a responsive grid of cards below the form.
- Use Tailwind CSS with a clean, dark theme. Show a loading state while saving.
Let's look at the API route AI generates, because this is where the real work happens:
// app/api/bookmarks/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
const body = await req.json()
const { url, title, tags } = body
// Validate URL format
try {
new URL(url)
} catch {
return NextResponse.json(
{ error: 'Invalid URL format' },
{ status: 400 }
)
}
const bookmark = await prisma.bookmark.create({
data: {
url,
title,
tags: tags || '',
favicon: `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`,
},
})
return NextResponse.json(bookmark, { status: 201 })
}
export async function GET() {
const bookmarks = await prisma.bookmark.findMany({
where: { archived: false },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(bookmarks)
}
What this does: The POST handler receives a URL, title, and tags from the form. It validates the URL using the built-in URL constructor — if the URL is malformed, it returns a 400 error immediately. Then it creates the bookmark in SQLite and auto-generates the favicon URL using Google's favicon service. The GET handler fetches all non-archived bookmarks, newest first.
This is a REST API — two endpoints, two HTTP methods, clean and predictable. Every web app you'll ever build uses this pattern.
Step 3: Adding Tags and Search
Tags and search are what separate a useful bookmark manager from a link graveyard. Here's the prompt to add them:
Add tags filtering and full-text search to the bookmark manager:
- Extract all unique tags from existing bookmarks and display them as clickable filter pills above the bookmark grid. Clicking a tag filters the grid to show only bookmarks with that tag. Clicking the active tag again clears the filter.
- Add a search input that filters bookmarks in real-time (client-side) across title, URL, and description fields. No server request needed — filter the already-loaded data.
- When both a tag filter and a search term are active, apply both — show only bookmarks that match the search AND have the tag.
- Show a count of visible bookmarks vs total bookmarks (e.g., "Showing 4 of 23 bookmarks").
- If no bookmarks match the current filter, show an empty state message like "No bookmarks found. Try a different search or tag."
The filtering logic AI generates is worth understanding — it's a pattern that shows up in every list-based UI:
// Client-side filtering — runs every time search or activeTag changes
const filteredBookmarks = useMemo(() => {
return bookmarks.filter(bookmark => {
// Tag filter: bookmark must include the active tag
const tagMatch = !activeTag ||
bookmark.tags.split(',').map(t => t.trim()).includes(activeTag)
// Search filter: title, url, or description must include the search term
const searchTerm = search.toLowerCase()
const searchMatch = !searchTerm ||
bookmark.title.toLowerCase().includes(searchTerm) ||
bookmark.url.toLowerCase().includes(searchTerm) ||
(bookmark.description || '').toLowerCase().includes(searchTerm)
return tagMatch && searchMatch
})
}, [bookmarks, activeTag, search])
What this does: useMemo recomputes the filtered list whenever bookmarks, activeTag, or search changes — but not on every render. The filter logic is two conditions joined with AND: the bookmark must match the active tag (if any) AND must contain the search term (if any). Both conditions are optional — if no tag is selected and no search term is typed, every bookmark passes.
This is client-side filtering, which means no server request happens when you type. All bookmarks are loaded once; JavaScript filters them in memory. For hundreds or even thousands of bookmarks, this is fast enough. If you had tens of thousands, you'd move the search to the server — but that's not a problem you'll have for a long time.
Step 4: Auto-Generating Descriptions with AI
This is the feature that makes the whole thing feel magical. Paste a URL, click "Describe," and AI writes a two-sentence summary of what the page is about. Here's how to build it:
Add an AI description generator to the bookmark manager:
- Add a "Describe" button to each bookmark card that has no description yet.
- Create an API route at POST /api/bookmarks/[id]/describe that: fetches the URL's HTML content server-side, extracts the page title and meta description (and up to 500 characters of body text as fallback), calls the OpenAI API (gpt-4o-mini model) with a prompt asking for a 1-2 sentence description of what the page is about, saves the generated description to the bookmark in the database, and returns the updated bookmark.
- Use the OPENAI_API_KEY environment variable for the API key. Never expose it client-side.
- Show a loading spinner on the "Describe" button while the API call is in progress.
- If the page can't be fetched (404, timeout, etc.), show a friendly error and let the user add a description manually.
Here's the describe route AI generates:
// app/api/bookmarks/[id]/describe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const bookmark = await prisma.bookmark.findUnique({
where: { id: parseInt(params.id) }
})
if (!bookmark) {
return NextResponse.json({ error: 'Bookmark not found' }, { status: 404 })
}
// Fetch page content server-side
let pageText = bookmark.url
try {
const res = await fetch(bookmark.url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(5000),
})
const html = await res.text()
// Extract meta description or first paragraph
const metaMatch = html.match(
/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i
)
const titleMatch = html.match(/<title>([^<]+)<\/title>/i)
pageText = [
titleMatch?.[1] || bookmark.title,
metaMatch?.[1] || '',
].join(' — ').slice(0, 500)
} catch {
// Fall back to title + URL if fetch fails
pageText = `${bookmark.title}: ${bookmark.url}`
}
// Ask AI for a description
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{
role: 'user',
content: `Write a 1-2 sentence description of this webpage for a personal bookmark manager. Be specific about what the page contains or explains. Page info: ${pageText}`
}],
max_tokens: 150,
})
const description = completion.choices[0].message.content || ''
// Save to database
const updated = await prisma.bookmark.update({
where: { id: parseInt(params.id) },
data: { description },
})
return NextResponse.json(updated)
}
What this does: The route fetches the bookmarked page server-side (so the OpenAI API key stays secret — never on the client), extracts the page title and meta description, and passes that context to GPT-4o mini. The model writes a concise description, which gets saved back to your SQLite database. Next time you load the page, the description is already there — no second API call needed.
Notice the signal: AbortSignal.timeout(5000) — that's a five-second timeout on the page fetch. Some pages are slow or block automated requests. Without a timeout, your API route hangs forever. AI often forgets this; make sure it's in your code.
The OpenAI API key lives in a .env.local file at the root of your project: OPENAI_API_KEY=sk-.... This file is never committed to git (your .gitignore should already exclude it). The key is used only in server-side API routes — never in client components. If you see NEXT_PUBLIC_OPENAI_API_KEY in AI-generated code, that's a mistake — the NEXT_PUBLIC_ prefix exposes the value to the browser. Remove it.
Step 5: Polish and Deploy
The app works. Now let's make it feel finished. Here are the polish prompts, followed by the deployment step:
Add these polish features to the bookmark manager:
- A delete button on each bookmark card (with a confirmation prompt before deleting)
- An "Archive" button that hides a bookmark from the main view without deleting it, plus an "Archived" toggle at the top to show/hide archived bookmarks
- An inline edit mode: clicking the title makes it editable in-place; pressing Enter or clicking away saves the change via PATCH /api/bookmarks/[id]
- A keyboard shortcut: pressing Cmd+K (or Ctrl+K) focuses the search input
- Animate bookmark cards in when they load using CSS transitions (fade + slide up)
- A total count in the header: "42 bookmarks saved"
For deployment, the simplest path is Vercel with AI. The one wrinkle with SQLite on Vercel: Vercel's serverless functions have an ephemeral filesystem, which means your dev.db file won't persist between deploys. For a deployed app, switch to Turso (a hosted SQLite service with a Prisma driver) or use a small Postgres database on Supabase.
Prompt for the deployment-ready version:
Make this bookmark manager ready for Vercel deployment:
- Switch the database from local SQLite to Turso (a hosted SQLite service). Use the @libsql/client adapter for Prisma.
- Move all environment variables to a .env.example file documenting what's needed: TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, OPENAI_API_KEY.
- Add a vercel.json if needed for any special configuration.
- Show me exactly how to set environment variables in the Vercel dashboard.
Once deployed, your bookmark manager is accessible from any device with a browser. You can also build a Chrome extension with AI that adds a one-click "Save to My Bookmarks" button in your browser — the extension calls your Vercel API directly.
What AI Gets Wrong About Bookmark Managers
AI generates a working bookmark manager on the first try about 75% of the time. Here's where the other 25% breaks — and how to catch it:
AI often skips duplicate checking. If you save the same URL twice, you get two entries. Fix this by adding a unique constraint in Prisma: @@unique([url]) on the Bookmark model. Then in the API route, use prisma.bookmark.upsert() instead of create() — it'll update the existing bookmark if the URL already exists, or create a new one if it doesn't.
Storing tags as a comma-separated string ("design,tools,inspiration") is the simplest approach, but filtering by tag requires a string match that can produce false positives. If you have a tag css and search for it, a bookmark tagged success might match. The fix AI often misses: always trim and lowercase tags on save, and use .split(',').map(t => t.trim()) when filtering — which is more exact than a substring match on the raw string.
Google's favicon service (https://www.google.com/s2/favicons?domain=...) works for 90% of sites but returns a blank icon for others. AI rarely adds a fallback. Add one: if the favicon image fails to load, show a generic link icon using the onError handler on the <img> tag. A simple globe icon SVG works perfectly.
Each "Describe" click calls the OpenAI API, which costs money. AI never adds rate limiting. For a personal app, this usually isn't a problem — but add a simple guard: if a bookmark already has a description, don't allow the describe endpoint to run again. Add this check at the top of the route: if (bookmark.description) return NextResponse.json(bookmark).
Next.js in development mode uses hot module reloading, which can create multiple Prisma client instances. Too many instances exhaust your database connection pool. AI sometimes forgets the singleton pattern. Make sure your lib/prisma.ts uses: const globalForPrisma = global as typeof global & { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient();
What to Build Next
Got your bookmark manager working? Here are the natural next steps — each is a single prompt away:
🔌 Chrome Extension Saver
Build a browser button that saves the current page with one click. No more copy-pasting URLs. See our guide on building a Chrome extension with AI — the extension calls your bookmark manager's POST /api/bookmarks endpoint directly.
📦 Import Chrome Bookmarks
Chrome exports bookmarks as an HTML file. Prompt: "Add an import feature that accepts a Chrome bookmarks HTML export file, parses all the anchor tags, and bulk-inserts them into the database with 'imported' as the default tag."
🗂️ Collections
Group bookmarks into named collections — "Reading List," "Project Research," "Recipes." Prompt: "Add a Collection model to the Prisma schema with id, name, and a many-to-many relation to Bookmark. Let users drag bookmarks into collections and filter by collection in the sidebar."
📝 Notes on Bookmarks
Add a notes field to each bookmark — your own thoughts, what was useful, action items. This bridges into a full notes app territory. Prompt: "Add a notes field (long text) to each bookmark. Show it as a collapsible section below the description. Auto-save notes as the user types with a 500ms debounce."
📧 Weekly Digest
Get an email every Monday with your bookmarks from the past week. Prompt: "Add a weekly digest email using Resend. Every Monday at 9 AM, send me the bookmarks I saved in the past 7 days, grouped by tag, with their AI-generated descriptions. Use a Vercel Cron Job to trigger it."
🤖 Smart Tagging
Let AI suggest tags automatically when you save a bookmark. Prompt: "When a bookmark is saved, call GPT-4o-mini to suggest 2-3 relevant tags based on the URL and title. Show the suggestions as clickable pills in the form — the user can accept, ignore, or edit them before saving."
Each of these ideas comes together faster than you'd expect because the foundation is already there. Once you understand how the save/read/search pattern works, adding new features is mostly about extending it. If you want to keep building, check out how to build a blog with AI — it reuses the same database and API patterns you just learned.
Frequently Asked Questions
About 45-60 minutes for a working version with save, tags, and search. Add another 30 minutes for AI-generated descriptions and deploy. This is one of the faster projects because the data model is simple — one table, a handful of fields — and AI generates clean CRUD code for it on the first prompt.
SQLite is a single file on your server — no account to create, no connection string to configure, no network latency. For a personal bookmark manager, it's perfect: fast, free, and zero infrastructure. You can always migrate to Postgres later if you need multi-user support or want to access your bookmarks from multiple machines.
No. You need to be able to describe what you want, paste prompts, and test whether the output works. AI writes all the JavaScript, API routes, and database queries. This guide explains what each piece does so you understand what you built and can debug it when something breaks — not so you write it yourself.
Yes, and it's a great stretch goal. Chrome lets you export bookmarks as an HTML file. Prompt AI: "Add an import feature that accepts a Chrome bookmarks HTML export file, parses all the links and titles, and bulk-inserts them into the database." AI handles the HTML parsing and bulk insert without breaking a sweat.
Deploy it to Vercel — the free tier is more than enough for a personal bookmark manager. Once deployed, your app is accessible from any device with a browser. You can also add it to your phone's home screen as a Progressive Web App (PWA). See our guide on deploying to Vercel with AI for the step-by-step process.