I bookmark everything. Articles, tools, random GitHub repos I tell myself I'll read later, tweets with "insane" takes I want to remember. My browser bookmark bar is a war crime. Hundreds of unsorted URLs just sitting there, gathering digital dust.
I tried every bookmark manager out there. Raindrop, Pocket, Notion—they all either asked me to pay monthly for something I could build, or forced me into their ecosystem. So I did what any developer with a mild case of "I can build that" syndrome would do:
I built my own.
Meet Sheltermark — a clean, minimalist bookmark manager that's just smart enough without being bloated.

The Problem
Browser bookmarks are fundamentally broken:
- They don't sync across different browsers (I use Chrome at work, Brave at home)
- No meaningful organization beyond folder nesting
- Zero metadata — just a title and URL, good luck finding anything later
- No way to share curated collections publicly
I wanted something that worked everywhere, auto-enriched my links, and let me organize them without friction.
Why Not Just Use Raindrop?
Fair question. Raindrop is great. But:
- I wanted full control over my data. Supabase means I own the database.
- I needed a Chrome extension that I could trust. No random permissions, no telemetry.
- I wanted to ship a side project from idea to production. Sometimes the best reason to build something is because you want to build it.
Like I wrote in my other post — you can overengineer a to-do list app. But a bookmark manager you'll actually use daily? That's worth getting right.
The Stack
- Frontend: Next.js 16 (App Router) + React 19 + Tailwind CSS v4 + shadcn/ui
- Backend: Supabase (Auth, PostgreSQL)
- State: TanStack Query
- Extension: Chrome Manifest V3 (vanilla TS)
- Package manager: Bun
I kept it boring on purpose. No edge functions, no microservices, no queue system. A monolith Next.js app with Supabase as the data layer.
Why Supabase?
I've used Supabase before and it ticks every box for a solo dev project:
- Auth out of the box (Google OAuth + email/password)
- PostgreSQL with Row-Level Security
- Storage if I need it later
- Free tier that handles way more traffic than I'll ever get
No need to stitch together Auth0 + Prisma + Redis + whatever. One SDK, done.
Auto-Metadata Fetching
The #1 feature I wanted: paste a URL, get its title, favicon, and OG image automatically. Sounds simple. It's not.
When you add a bookmark, the server fetches the URL, parses the HTML with Cheerio, extracts <title>, og:image, and favicon from the <link> tags. But some sites block bots, return 403s, or serve JavaScript-rendered content. I had to handle:
- Timeouts (some sites just hang forever)
- Redirect chains (thanks, link shorteners)
- Missing metadata (fallback to domain favicon via Google's favicon API)
- Rate limiting (don't hammer the same domain)
// Simplified metadata fetchasync function fetchMetadata(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(5000), headers: { "User-Agent": "Sheltermark/1.0" }, }); const html = await response.text(); const $ = cheerio.load(html); return { title: $('meta[property="og:title"]').attr("content") || $("title").text() || url, favicon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href"), ogImage: $('meta[property="og:image"]').attr("content"), }; } catch { return { title: url, favicon: null, ogImage: null }; }}Chrome Extension Auth
The extension needs to authenticate with Supabase. But extensions live in an isolated context — no cookies, no same-origin. The trick? Use Supabase's getSession() with the auth token stored in chrome.storage.local, shared via the background service worker.
The flow:
- User authenticates on the web app
supabase.auth.getSession()returns the session- Extension popup calls
chrome.runtime.sendMessage()to the background worker - Background worker makes authenticated API calls to the Next.js server
It's janky, but Chrome extensions and third-party auth are fundamentally awkward together. It works.
Workspace-Level Permissions
Workspaces can be public or private. Public ones are readable by anyone without auth. Private ones are locked behind the user's session.
This meant two query patterns:
// Public workspace: anyone can seeconst { data } = await supabase .from("workspaces") .select("*") .eq("id", workspaceId) .eq("is_public", true);// Private workspace: must be the ownerconst { data } = await supabase .from("workspaces") .select("*") .eq("id", workspaceId) .eq("user_id", userId);Row-Level Security in PostgreSQL makes this clean. One table, one RLS policy, two behaviors.
Data Flow: Server Actions + Optimistic Updates
I initially thought I'd need Supabase Realtime for cross-device sync. Turns out, I didn't. The flow is simpler than that.
Every mutation (add, delete, move, rename) uses optimistic updates with TanStack Query:
// Simplified from useAddBookmarkconst mutation = useMutation({ mutationFn: (data) => addBookmark(data), // Optimistic: show the bookmark immediately onMutate: async (data) => { await queryClient.cancelQueries({ queryKey: bookmarkKeys.all }); const previous = queryClient.getQueryData(bookmarkKeys.all); queryClient.setQueryData(bookmarkKeys.all, (old = []) => [ { id: `temp-${Date.now()}`, url: data.url, /* ... */ }, ...old, ]); return { previousBookmarks: previous }; }, // On error: roll back to previous state onError: (error, variables, context) => { queryClient.setQueryData(bookmarkKeys.all, context?.previousBookmarks); toast.error("Failed to add bookmark"); }, // On settle: refetch from server onSettled: () => { queryClient.invalidateQueries({ queryKey: bookmarkKeys.all }); },});The pattern: optimistically update the cache → mutate on the server → invalidate to refetch. No WebSocket, no polling, no real-time subscriptions. Just invalidateQueries triggering a fresh fetch from the server action.
Cross-device sync works naturally: extension saves a bookmark via server action → next time the web app refetches (on mutation settle or page focus), it pulls the fresh data. Same database, same query, eventually consistent.
I also built a reusable createOptimisticMutation helper so I don't repeat this pattern for every mutation — just pass the query key and the optimistic data transform function.
What I'd Do Differently
- Better error handling for metadata fetch: Some sites are just broken. I need a retry queue.
- Import from browser: Chrome exports HTML, Firefox uses JSON. Supporting native browser imports would remove the last friction point for new users.
- Full-text search: Right now it's basic
ILIKEon title/URL. Postgres full-text search would be way better.
The Chrome Extension
This was surprisingly fun. A popup with a save button, a workspace selector, and a toast. No UI framework, just raw HTML + vanilla TypeScript compiled with esbuild.
The extension reads the current tab's URL, sends it to the background script, which calls the API with the stored auth token. The hardest part? Handling the auth flow when the user isn't logged in — redirecting them to the web app to sign in and then coming back.
It's not on the Chrome Web Store yet (still figuring out that $5 registration fee, lol). But you can side-load it from the GitHub releases.
The "Why" Behind Building It
I'm a firm believer that building tools for yourself is one of the best ways to level up as a developer. You care about the outcome because you're the user. You iterate faster because you feel the pain of every missing feature.
Sheltermark is useful to me. If it's useful to you too, that's cool. But even if it isn't, I learned a ton:
- Optimistic update patterns with TanStack Query
- Supabase RLS policies and server action architecture
- Chrome extension architecture (way more nuanced than I expected)
- Metadata scraping and server-side parsing patterns
- How to structure a Next.js 16 app with the App Router properly
Try It
- Web app: sheltermark.vercel.app
- Source code: github.com/AdityaZxxx/sheltermark
If you try it and something breaks, open an issue. I'll probably fix it — I'm using this thing every day.
This is live and actively maintained. Roadmap: full-text search, bookmark tagging, and maybe finally paying that $5 to get the extension on the Chrome Web Store.


