Building a Pinboard Clone in a Weekend

Cowpin

Cowpin

3/8/2026

#cowpin #pinboard #nuxt #prisma #trpc
Building a Pinboard Clone in a Weekend

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:

  1. 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.
  2. (userId, slug) is unique, not (userId, name). The slug is computed at write time (lowercase, kebab, Hangul-aware). So #AI, #ai, and # AI all collapse to one row, but the user's chosen capitalization is preserved in name.
  3. isArchived is a flag, not a separate state. An archived bookmark is still a normal bookmark — it just happens to also have archivedTitle / archivedExcerpt populated and is hidden from public listings. That keeps the queries simple and lets you "un-archive" trivially.
  4. ApiToken is 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.
  5. 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:

  1. 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",
]);
  1. Profiles live under /u/, not at the root. This was tempting — cowpin.com/cowpin is prettier than cowpin.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.