Building a Pinboard Clone in a Weekend

Cowpin
3/8/2026

Pinboard.in is the best bookmark manager ever made, and it's been steadily decaying for years. The author moved on, the UI never got modernized, the import/export remained brittle, mobile is unusable.
This weekend I rebuilt the pieces I actually use, in Cowpin.
Scope, tightly
Pinboard has dozens of features. I committed to only the ones I use weekly:
- Save a URL with title, short description, long-form notes
- Tag it
- Mark it private or public
- Mark it "read later"
- "Archive" — snapshot the title + excerpt so the bookmark survives link rot
- Public profile at
/u/<username>with an RSS feed - A bookmarklet so I can save from any browser tab in one click
That's it. No social graph, no team accounts, no folders, no AI suggestions.
Stack
Cowpin is a Nuxt 3 + tRPC + Prisma + Postgres monorepo (forked originally from Supastarter). That stack happens to be perfect for this:
- Prisma for the bookmark / tag / token schema
- tRPC for the typed API the dashboard calls
- Raw Nitro server routes for the bookmarklet endpoint and RSS feed (because bookmarklets and feed readers don't speak tRPC)
- @nuxt/content for this blog you're reading
- Tailwind + shadcn-vue for the UI
The schema
Four new models. Notable choices below the diagram.
model Bookmark {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
url String
title String
description String?
notes String?
isPrivate Boolean @default(false)
isReadLater Boolean @default(false)
isArchived Boolean @default(false)
archivedTitle String?
archivedExcerpt String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags BookmarkTag[]
@@index([userId, createdAt(sort: Desc)])
@@index([userId, isReadLater])
@@index([userId, isArchived])
@@index([userId, isPrivate])
}
model Tag {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
name String
slug String
bookmarks BookmarkTag[]
@@unique([userId, slug])
}
model BookmarkTag {
bookmark Bookmark @relation(fields: [bookmarkId], references: [id], onDelete: Cascade)
bookmarkId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
tagId String
@@id([bookmarkId, tagId])
}
model ApiToken {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
name String
hashedToken String @unique
createdAt DateTime @default(now())
lastUsedAt DateTime?
}
A few things I want to call out:
- Tags belong to a user, not globally. This means my "ai" tag and your "ai" tag are different rows. Pinboard does the same; it makes tag rename / merge a single-user operation and avoids the "what does #ai mean to a million people" problem.
(userId, slug)is unique, not(userId, name). The slug is computed at write time (lowercase, kebab, Hangul-aware). So#AI,#ai, and# AIall collapse to one row, but the user's chosen capitalization is preserved inname.isArchivedis a flag, not a separate state. An archived bookmark is still a normal bookmark — it just happens to also havearchivedTitle/archivedExcerptpopulated and is hidden from public listings. That keeps the queries simple and lets you "un-archive" trivially.ApiTokenis hashed at rest. The plaintext is shown to the user exactly once at creation. SHA-256 is fine for this — it's a long random token, not a password — and avoids the cost of bcrypt on every bookmarklet click.- The four bookmark indexes are deliberately narrow. Each matches a
common filter combination from the dashboard (
/app/bookmarks?filter=readLater, etc.). Postgres can use them effectively without us needing composite indexes for every combination.
The tRPC router
Twelve procedures, one file each, mounted at bookmarks.*:
bookmarks.list ← search + tag + readLater + archived filters, cursor-paginated
bookmarks.byId
bookmarks.create
bookmarks.update
bookmarks.remove
bookmarks.archive ← fetch URL, store title + excerpt
bookmarks.tags ← list with bookmark counts
bookmarks.fetchMetadata ← autofill helper for the form
bookmarks.publicProfile ← public, by username
bookmarks.publicBookmarks ← public, paginated, by username
bookmarks.createApiToken
bookmarks.listApiTokens
bookmarks.deleteApiToken
The whole thing came in under 500 lines because tRPC + Prisma + Zod do the heavy lifting. Every procedure is end-to-end type-safe from the database row to the Vue component prop.
The unobvious bits
Three details that took longer than expected:
1. URL snapshot without a parser dependency
The "archive" feature fetches the URL and stores its title + first 1000 characters of body text, so the bookmark survives link rot.
I did NOT want to add cheerio or jsdom for this. The
fetchUrlSnapshot helper uses plain regexes:
function extractTitle(html: string): string | null {
const og = extractMeta(html, "og:title");
if (og) return og;
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return m ? decodeHtmlEntities(m[1]!.trim()) : null;
}
function extractExcerpt(html: string): string | null {
const og = extractMeta(html, "og:description") ?? extractMeta(html, "description");
if (og) return og.slice(0, 1000);
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const raw = bodyMatch ? bodyMatch[1]! : html;
return raw
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 1000);
}
Plus an AbortController timeout, a Content-Type guard, and a 2 MB
download cap so a malicious page can't OOM the server. About 80 lines
total, zero dependencies.
2. Bookmarklet auth without sessions
The bookmarklet runs in random tabs across the web. It can't share the Cowpin session cookie (different origin), so it needs its own auth mechanism.
We mint per-user API tokens — long random strings, hashed in the DB,
sent as Authorization: Bearer cowpin_xxx. The quick-add endpoint
looks them up by hashedToken (which is unique, so it's an indexed
single-row lookup) and creates the bookmark on the matched user's behalf.
const auth = getRequestHeader(event, "authorization") ?? "";
const m = auth.match(/^Bearer\s+(\S+)$/);
if (!m) throw createError({ statusCode: 401 });
const tokenRow = await db.apiToken.findUnique({
where: { hashedToken: hashApiToken(m[1]!) },
});
Same pattern works for a future browser extension or an iOS Shortcut.
3. Username collision with route paths
This is the bug everyone hits exactly once and then never forgets.
I want public profiles at /u/<username>. But /auth, /blog,
/api, /admin, /_nuxt are already routes. If I let someone register
the username auth, anyone visiting /u/auth works, but anyone trying
to register a new user gets unpredictable behavior at /auth.
Two defenses, both belt-and-suspenders:
- Reserved usernames. A literal set in the username validator:
const RESERVED_USERNAMES = new Set([
"admin", "api", "app", "auth", "blog", "settings", "u", "user", "users",
"team", "teams", "feed", "rss", "static", "public", "images", "docs",
"changelog", "pricing", "login", "signup", "logout", "_nuxt",
]);
- Profiles live under
/u/, not at the root. This was tempting —cowpin.com/cowpinis prettier thancowpin.com/u/cowpin— but it collides with literally every product page now and forever. Not worth it.
What's still missing
I shipped the dashboard list/add/edit, the public profile + RSS, and the bookmarklet endpoint. Things I deliberately deferred:
- A settings panel UI to set your username, bio, public toggle, and manage API tokens. Right now you'd do it via the tRPC API directly.
- A tag management page for rename/merge.
- Daily digest email of what you saved that day. The cron + email template work; no UI yet.
- A browser extension. The API is extension-ready; the extension itself is just packaging.
These will land over the next couple of weekends. The point of v1 was to get to "I can save links from anywhere and read them anywhere" — and that works today.
Why bother?
Because bookmarks are forever, and your bookmark host should be too. Pinboard barely runs anymore. Pocket got bought and quietly dismantled. Raindrop is fine but you don't own it. Toby and Notion-as-bookmarks both require you to keep paying.
Cowpin is SaaS — we run the app; you use it in the browser like any hosted service. Your bookmarks stay portable: you can export as JSON whenever you want a copy or to leave, and we keep shipping improvements — not a one-off side project that goes quiet. That's what we want from a bookmark service in 2026.