1
1 Comment

I shipped Leap β€” an Arc-style sidebar for Chrome. Here's the stack and the 3 decisions that shaped it.

Hey IH πŸ‘‹

I just pushed Leap to the Chrome Web Store β€” a vertical-sidebar extension that gives regular Chrome the Arc Browser experience: Spaces, vertical tabs, pinned sites, nested folders, keyboard shortcuts, cloud sync. Tagline: "Leap between spaces."

I didn't want to switch browsers. I wanted Arc's workflow inside the browser I already use. So I built it.

Posting here as a build-in-public write-up β€” the three architectural decisions that mattered, the stack, and the traps I wish someone had warned me about. Would love your feedback.


Decision 1: Spaces = native Chrome Tab Groups (not a custom overlay)

The obvious move is to render your own tab list in a React sidebar and pretend Chrome's native UI doesn't exist. I didn't do that.

Every Leap "Space" is a real chrome.tabGroups group under the hood. Switching a space means: collapse every group β†’ expand the target β†’ activate its first tab.

Why it matters: in MV3, chrome.tabs.hide() is not available to regular extensions. You cannot hide tabs to emulate spaces. You can collapse tab groups β€” and a collapsed group visually disappears from the tab strip while keeping the tabs alive. That single API constraint drove the whole architecture.

Upside: zero desync between "what Leap thinks" and "what Chrome thinks". Downside: you're bound to tab-group semantics (one group per tab, group color enum, etc.) forever.

Decision 2: Local-first with debounced background sync

All writes go to chrome.storage.local first. The service worker then debounces a sync to the backend. The UI never blocks on the network.

  • Zustand for local state (spaces, tabs, settings, auth)
  • TanStack Query for server state (sync, sessions, AI calls)
  • A single sync boundary β€” components never call fetch directly, they only go through query hooks in queries/

The mental model: local is the source of truth at read time, server is the source of truth at reconcile time. It's the only pattern that gives you the Arc-like instant feel while still having cloud sync across devices.

Decision 3: Firebase + chrome.identity = one login, forever

This was the most pleasant surprise. The flow:

  1. chrome.identity.getAuthToken() grabs a Google OAuth token from the signed-in Chrome profile.
  2. Exchange it via signInWithCredential() into a Firebase session.
  3. Firebase persists the session in IndexedDB.

Net result: the user clicks "Sign in with Google" once. That's it. Forever. No token refresh UI, no re-login after a restart, no cookie jar issues. For a Chrome extension this is gold β€” auth friction was my biggest fear and it turned out to be a 40-line integration.


The full stack

Extension: React 18, TypeScript strict (no any), Tailwind 3, Zustand, TanStack Query, Framer Motion (spring-based, Apple-aesthetic micro-animations), Vite + CRXJS, Manifest V3.

Server: Go 1.24, Fiber v3, goqu v9 (SQL builder) + pgx v5, Neon Postgres, Firebase Admin, go-playground/validator, log/slog. Plain structs as models, manual DI, golang-migrate for schema.

Monorepo: Turborepo + pnpm, a [@leap](/leap)/shared package for the TypeScript types (Go produces the matching JSON via struct tags).

Infra: Neon (DB), Railway (Go server, Docker), Firebase (auth), Biome for the TS side.

Things that bit me

  • Manifest V3 service workers die. Any in-memory state in the background script is gone the moment Chrome decides to kill the worker. Everything that needs to persist goes through chrome.storage. I learned this the hard way with a debounce timer that silently stopped firing.
  • Tab group APIs are flaky under rapid switching. Collapsing and expanding groups in quick succession can leave the window in a weird state. I added a 40ms guard between operations β€” problem gone.
  • Framer Motion in an extension sidebar is fine, but watch the bundle. CRXJS does tree-shake well, but Framer is heavy. I import only from the core package and it stays under budget.
  • Folders nested 3 levels deep required a parentId + depth schema β€” easier to enforce depth at write time than to chase orphaned nodes later.

Where it is now

Leap is live on the Chrome Web Store right now: πŸ‘‰ Install Leap

Free, no account needed to try locally β€” sign in only if you want cross-device sync.

The ask

  • Try it for a day and tell me where it feels wrong. Arc users especially β€” what did I miss?
  • I'm debating opening the server as a self-hostable option. Is that something any of you would actually run, or is it just nerd candy?
  • If you've shipped a Chrome extension to 1k+ installs, I would love to hear what you did for onboarding and retention β€” that's my next hill to climb.

Happy to answer anything about the stack, the MV3 constraints, or the build-in-public journey in the comments. πŸ™

β€” Roman

on April 17, 2026
  1. 1

    I actually know a few Chrome extension founders who've scaled past 1k installs personally. Happy to ask them if they'd answer some of your questions about onboarding and retention for free.

Trending on Indie Hackers
How I built an AI workflow with preview, approval, and monitoring User Avatar 64 comments Show IH: I'm building a lead gen + CRM tool for web designers targeting local businesses without websites β€” starting with Spain User Avatar 62 comments I built a URL indexing SaaS in 40 days β€” here's the honest story User Avatar 53 comments After 4 landing page rewrites, I finally figured out why my analytics SaaS wasn't converting User Avatar 21 comments We witnessed a sharp spike in our traffic. So much happiness after a long time. User Avatar 15 comments Creative Generator β€” create product-focused visuals and ad concepts faster User Avatar 11 comments