How to build a bookmark manager
How to build a bookmark manager with folders, tags, and a search box. Follow the prompts, check each step, then skip the full-page archive for later.
You can learn how to build a bookmark manager as folders, tags, and a search box that finds a half-remembered link. Raindrop.io is that product. The catalog line is “Every link you swore you’d revisit, findable this time.”
You pay $2.33–$3 a month. That is $28–$36 a year. The verdict is “Satisfyingly simple”. The first estimate is 3 hours: save links, tags, search.
There is no standalone teardown yet. This guide is the walk. Prompts in order, then what to look at.
The audience is one person hoarding links across every device. If you need team sharing, public collections, or a reader that stores the whole article, keep paying. The same v1 also replaces Pinboard and GoodLinks.
Readwise Reader is the read-later cousin, $10–$12.99 a month, about 2 days. That card stores the article body so you can highlight it later, and this one stores the URL. A feed you fetch on a schedule is Feedly, $7–$12 a month, about 8 hours.
How to build a bookmark manager without a full-page archive
The Raindrop.io page splits the work into three rows.
The first row is the one you want. Paste a URL. Get a title back. Put the row in a folder. Tag it. Find it again from a search box. That is the 3-hour v1.
The second row is 1 day. A bookmarklet, richer collections, duplicate detection that ignores utm_source, stored excerpts. Do that after you have fifty links you can actually find.
The third row is 1 month and up. Browser extensions, mobile apps, full-page archiving. Skip it.
If you tell an agent to “clone Raindrop”, it aims at the third row. Pin every prompt to the first one.
The card lists three entities: Bookmark, Collection, Tag. Collection is the folder. Tonight every bookmark lives in one folder. Inbox is the default. Writing is the first named one.
Here is the shape of the data. Nothing else has to exist at 3 hours.
const collection = {
id: 'col-writing',
ownerId: 'flavio',
name: 'Writing',
slug: 'writing',
}
const bookmark = {
id: 'bm-hosting',
ownerId: 'flavio',
collectionId: 'col-writing',
url: 'https://flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare/?utm_source=newsletter',
normalizedUrl: 'https://flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare',
title: 'How to host a SaaS clone on Cloudflare',
description: 'The adapter, then the bindings, then the custom domain.',
excerpt: null,
favorite: false,
createdAt: '2026-10-28T09:00:00.000Z',
}
const tag = {
id: 'tag-hosting',
ownerId: 'flavio',
name: 'hosting',
}
const bookmarkTag = {
bookmarkId: 'bm-hosting',
tagId: 'tag-hosting',
}
A folder, a bookmark, a tag, and the join row. excerpt stays null on the 3-hour row. Search tonight reads title and description.
bookmarks.normalized_url is unique per owner. collections.slug is unique per owner. tags.name is unique per owner. bookmark_tags is unique on (bookmark_id, tag_id).
Pick the stack you already write
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Raindrop.io is Astro, HTMX, and D1.
This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The fetch still has to run on the server. A pretty card grid does not replace the timeout and the private-range reject list.
Open Build your own Raindrop.io. The guardrail is the useful sentence: fetch metadata server-side with timeouts and size caps, and store only the excerpt needed for search. Tonight there is no excerpt column with text in it. Keep the timeout and the size cap anyway.
The non-goal sits next to it. No team sharing, public collections, AI summaries, or a full read-later reading experience.
The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the archive waits. Pick one stack and stay on it.
Prompt 1. Scaffold Pincushion and stop
Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 3-hour row only. Save
links, tags, search.
Project name: Pincushion.
Public URL is https://pins.flaviocopes.com.
Owner is Flavio.
Seed folder Writing.
Seed Inbox as the default folder.
First bookmark:
https://flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare/
Tags: hosting, cloudflare.
Do not add a bookmarklet, stored
excerpts, or duplicate merge UI yet.
Do not add browser extensions,
mobile apps, full-page archiving,
team sharing, public collections,
AI summaries, or a reader view.
When the plan is done, stop and wait.
Do not write implementation code
until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want pages like these:
/for the library, newest first/savefor the URL field/folders/writingfor one collection/tags/hostingfor one tag/search?q=cloudflarefor the box
You want tables collections, bookmarks, tags, bookmark_tags. archives should not appear. highlights should not appear. share_links should not appear.
If the plan includes a headless Chrome render, an extension zip, or a public profile, send it back. Paste the non-goal again.
When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.
What to check after the scaffold
Start the app. Sign in as Flavio. You should see an empty library, Inbox, and a save action.
Open the migrations. bookmarks has url and normalized_url. There is a unique index on (owner_id, normalized_url). collections has a unique (owner_id, slug). There is no html or screenshot column.
If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to name the fetch timeout, the private-range reject list, and how search is indexed.
Prompt 2. Save a public URL and keep one copy
The first mini-prompt on the card is “The same link, saved twice, stored once”. The second is “Fetching strangers’ pages without getting hurt”. Tonight those are one save form.
You type a URL. The server resolves the host, refuses private addresses, fetches a few kilobytes, and reads a title. A newsletter link with utm_source must match the clean URL you saved yesterday.
Build the save form for Pincushion.
Flavio pastes a URL on /save.
Accept only http and https.
Before save and before every
fetch, resolve the hostname.
Reject loopback, RFC 1918,
link-local, and
169.254.169.254. Connect to the
vetted IP and send the original
Host header.
Normalize before the unique check:
lowercase scheme and host, drop
default ports, drop fragments,
strip a trailing slash on the
path, drop utm_*, fbclid, gclid,
and ref. Keep meaningful query
strings. A YouTube v= id stays.
Store the original URL for
opening and normalizedUrl for
matching.
Fetch title, meta description,
and favicon href. Timeout 5
seconds. Read at most 64 KB.
Follow at most 3 redirects.
Each hop must pass the same
private-range checks. Parse
HTML. Do not execute scripts.
Do not store the page body.
A fetch failure still saves the
bookmark. Title starts as the
hostname and stays editable.
If normalizedUrl already exists
for Flavio, update title and
description when the new fetch
is better, keep createdAt, and
do not insert a second row.
Seed bm-hosting in Writing.
Done when I can save that
Cloudflare post, save it again
with ?utm_source=newsletter,
see one row, and saving
http://127.0.0.1/ is rejected.
const TRACKING = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'ref']
function normalizeUrl(raw) {
const url = new URL(raw)
url.protocol = url.protocol.toLowerCase()
url.hostname = url.hostname.toLowerCase()
if (url.protocol === 'http:' && url.port === '80') url.port = ''
if (url.protocol === 'https:' && url.port === '443') url.port = ''
url.hash = ''
for (const key of [...url.searchParams.keys()]) {
if (TRACKING.includes(key) || key.startsWith('utm_')) {
url.searchParams.delete(key)
}
}
url.searchParams.sort()
let path = url.pathname
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1)
return `${url.protocol}//${url.host}${path}${url.search}`
}
normalizeUrl('https://Flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare/?utm_source=newsletter')
//'https://flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare'
The unique index is on that string. The library still opens the original URL, tracking junk and all, because that is the page you meant.
const BLOCKED = [
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'169.254.0.0/16',
]
function isPublicTarget(raw, resolvedIp) {
const parsed = new URL(raw)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
return !BLOCKED.some((cidr) => inRange(resolvedIp, cidr))
}
isPublicTarget('https://flaviocopes.com/', '1.1.1.1')
//true
isPublicTarget('http://127.0.0.1/', '127.0.0.1')
//false
1.1.1.1 is public. 10.0.0.4 is not. The form and the fetch share this function. The uptime monitor guide uses the same reject list for a cron. Here the fetch happens once, when you save.
What to check after the URL saves
Paste https://flaviocopes.com/how-to-host-a-saas-clone-on-cloudflare/. The row shows the post title, not the raw host.
Paste the same URL with ?utm_source=newsletter. Still one row. createdAt did not move.
Try http://127.0.0.1/. The form refuses. Try http://169.254.169.254/. Same refusal. Try http://10.0.0.4/. Same.
Save a URL that times out, or a site that blocks bots. The row still appears. Title is the hostname. You can edit it.
If the agent stored the HTML body, delete that column. Title, description, and the two URL fields are enough.
Prompt 3. File it in a folder and tag it
You came here for folders, tags, and a search box. Tags are on the 3-hour row. Collection is already an entity on the card, so give every bookmark a folder tonight.
Inbox cannot be deleted. A named folder can. Bookmarks in a deleted folder move back to Inbox.
Build folders and tags for
Pincushion.
Every bookmark has one
collectionId. Seed Inbox
(slug inbox) and Writing
(slug writing). Creating a
folder asks for a name and
builds a lowercase slug.
Reject a second Writing.
The library sidebar lists
folders with counts. Opening
/folders/writing shows only
that folder. A bookmark can
move from Inbox to Writing
on the edit form.
Deleting Writing moves its
bookmarks to Inbox. Inbox
has no delete button.
Tags are many-to-many.
Names are lowercase, trimmed,
unique per owner. Seed
hosting and cloudflare on
bm-hosting.
/tags/hosting lists every
bookmark with that tag.
Removing a tag from the last
bookmark deletes the tag row.
Favorites are a boolean on
the bookmark. /favorites
lists those rows.
Do not add nested folders.
Do not add public collection
links.
Do not add a bookmarklet.
Done when bm-hosting sits in
Writing with hosting and
cloudflare, I can move it to
Inbox and back, and deleting
Writing leaves the bookmark
in Inbox.
function slugifyName(name) {
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
slugifyName('Writing')
//'writing'
slugifyName(' Hosting Tips ')
//'hosting-tips'
One slug function for folders and tags. Writing and writing are the same folder.
async function deleteFolder(db, ownerId, slug) {
if (slug === 'inbox') {
throw new Error('Inbox cannot be deleted')
}
return db.transaction(async (tx) => {
const folder = await tx.getFolder(ownerId, slug)
const inbox = await tx.getFolder(ownerId, 'inbox')
await tx.moveBookmarks(folder.id, inbox.id)
await tx.deleteFolder(folder.id)
})
}
The move and the delete share one transaction. A refresh never shows a bookmark whose folder is gone.
What to check after the folders fill
Open /folders/writing. One card. Tags hosting and cloudflare.
Move the card to Inbox. /folders/writing is empty. The sidebar count on Inbox went up by one. Move it back.
Create a second folder named writing. The form refuses.
Add tag d1. /tags/d1 shows the same card. Remove d1. The tag page is gone.
Star the card. /favorites shows it. Unstar. The list is empty.
Delete Writing. bm-hosting is in Inbox. No 404 on /.
If the agent built nested folders, stop. A flat list is the 3-hour row.
Prompt 4. Find it from a half-remembered phrase
The third mini-prompt is “Find it by the phrase you half-remember”. That prompt wants excerpts and FTS highlights. Excerpts are the 1-day row. Tonight the index covers title and description, because those are the fields you already store.
Build search for Pincushion.
One box on every library page.
GET /search?q=cloudflare
searches title, description,
and the original URL.
Use FTS5, or the equivalent
full-text index, not LIKE.
Weight title matches above
description matches. Multi-word
queries are AND. The last term
may be a prefix so results
narrow while typing.
Combine the query with folder
and tag filters in SQL. Do not
load the library and filter it
in JavaScript.
Highlight the matched terms in
the title and description.
Escape the snippet. These
strings came from other
people's pages.
Keep the index in the same
transaction as insert, edit,
and delete. A deleted bookmark
must disappear from /search
on the next request.
Empty query shows a short tip,
not the whole library.
Do not search an excerpt
column.
Do not add AI suggestions.
Done when q=cloudflare returns
bm-hosting, q=adapter returns
it from the description, and
deleting the row makes the
same query empty.
function searchBookmarks(db, ownerId, query, filters) {
const terms = query.trim().split(/\s+/).filter(Boolean)
if (terms.length === 0) return []
const prefix = terms.map((term, i) => {
const safe = term.replace(/["*]/g, '')
return i === terms.length - 1 ? `${safe}*` : safe
}).join(' AND ')
return db.search({
ownerId,
match: prefix,
collectionId: filters.collectionId,
tag: filters.tag,
})
}
searchBookmarks(db, 'flavio', 'cloud', {})
//[{ id: 'bm-hosting', title: 'How to host a SaaS clone on Cloudflare' }]
cloud still hits Cloudflare because the last term is a prefix. adapter hits the description. A LIKE '%cloud%' scan will feel fine with twelve rows and die at a few thousand.
What to check after search works
Type cloudflare in the box. bm-hosting is first. The word is highlighted in the title.
Type adapter. Same row, highlight in the description.
Type cloud. Still that row.
Filter to tag hosting and query cloudflare. Still one hit. Filter to a tag the row does not have. Empty.
Delete the bookmark. Search again. Empty.
Save a second link whose title is unrelated and whose description mentions Rome. Query Rome. Only the second row.
If the agent wrote WHERE title LIKE ?, send prompt 4 back. D1 speaks FTS5.
Failures the agent ships anyway
A second row for the same article
utm_source is noise. If the newsletter link and the clean permalink both exist, the unique index is on the wrong column. Index normalized_url, not url.
Fetching whatever the form accepted
http://127.0.0.1/ and a metadata address have to fail at save and at fetch time. Resolve again before the GET. The uptime monitor guide is the same rule on a cron. Here you have no cron, and you still need the check.
The whole HTML page in D1
The guardrail says store only the excerpt needed for search. Tonight you do not even need the excerpt. A 200 KB homepage in a text column is the 1-month archive wearing a different name.
A public folder URL
/folders/writing is Flavio’s library. It is not a Linktree. The Linktree guide is the public page: a profile, five links, one theme. This app is private. If the plan has a share token on a collection, you left the first estimate.
A short public URL is a different job. That is Bitly, $35–$300 a month, about 90 minutes. I wrote that walkthrough as Build your own link shortener step by step.
A reader that stores the article
That is Readwise Reader. Two days, $10–$12.99 a month. Inbox, archive, highlights. The Raindrop non-goal names that product on purpose.
Deploy the library
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Pincushion wants Astro, HTMX, and D1. Put it on a subdomain you already own, pins.flaviocopes.com. D1 holds the tables and the FTS index. There is no cron on the 3-hour row. Save is an ordinary form post.
The hosting post is that path: the adapter, then the bindings, then the custom domain.
Attach the domain on the Worker. Sign in as Flavio from your phone on cellular. Paste a URL you have open. Confirm the laptop library shows the same card after a refresh. Search the title from the phone.
Ship with Writing, the hosting post, and twenty of your own links. Search a phrase you only half remember. If the right card is first, the v1 works.
Take a D1 backup before you delete the paid account. The library is the history you will miss, so treat that backup like a password.
Use it as your real dump pile for a week, not as a demo. If you miss a bookmarklet or a stored excerpt, those are the 1-day row and the other mini-prompts on the card. Add them after search has been wrong once, or right every day.
Then cancel Raindrop.io, or Pinboard, whichever one you pay for the library.
What to skip on purpose
Leave out browser extensions, mobile apps, full-page archiving, team sharing, public collections, AI summaries, and a reader view.
The bookmarklet is the fourth mini-prompt on the card. I would not build it until /save is a habit. A token that can only create bookmarks, plus a rotate button, is the whole trick. Do that on the 1-day row.
A passworded file someone else opens is DocSend, $65–$180 a month, about 4 hours. I wrote that walkthrough as How to build a DocSend alternative.
A feed that arrives on its own is Feedly. Poll that list on a cron. Leave it off Pincushion.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Raindrop.io. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only: save links, tags, search. Three hours.
Point it at twenty links you already lost once. Search one phrase. If the right card is first, you can cancel.