Marginalia v2 — Full Architecture & Implementation Plan
Update (2026-04-15) — Decisions made during scaffolding:
- Repo name:
marginalia-cloud (avoids v2 temporal naming). Lives at https://github.com/galsapir/marginalia-cloud (private during dev, will go public).
- Hosting flipped from Cloudflare Pages → Cloudflare Workers w/ static assets. Per CF official guidance for new projects in 2026, all investment goes to Workers; Pages is feature-frozen. Bonus: one Worker now serves both the React SPA and
/api/* routes (truly same-origin, no CORS config, Access cookies trivial). Sections below have been updated to reflect this.
- Jina proxy stays separate. The existing
marginalia-reader Worker is unauthenticated; the new sync Worker will be Access-protected. Two Workers, one job each — avoids carving a public route through Access.
- Phase 1 + Phase 2 likely merge. Porting modules just to "verify identical to v1" is wasted motion when Phase 2 will rewrite all the persistence wiring anyway. Port modules as they're wired into Dexie.
- Initial scaffold landed: Vite 8 + React 19 + TS 6 + Tailwind 4 +
@cloudflare/vite-plugin + wrangler. Builds clean. Worker entry stubs /api/* with 501.
Overview
Evolve Marginalia from a client-side-only markdown annotation tool into a persistent, authenticated experience — a "Google Docs for markdown annotation." This will be a new repository (marginalia-cloud), porting the core rendering/annotation engine from the current repo. The current repo stays live on GitHub Pages with a banner pointing to the new version.
What Exists Today (v1)
Tech Stack
- Frontend: React 19 + TypeScript 5.9 + Vite 7 + Tailwind CSS 4
- Hosting: GitHub Pages (static site, deployed via GitHub Actions)
- Worker: One Cloudflare Worker as CORS proxy for Jina Reader webpage-to-markdown conversion (
https://marginalia-reader.galsapir.workers.dev)
- State: Entirely in-memory React state. No persistence. Annotations lost on refresh.
- Auth: None
Core Engine (to be ported to v2)
These are the battle-tested modules that should be carried over:
| Module |
Path |
Purpose |
rehypeAnnotationMarks.ts |
src/lib/ |
Single-pass AST injection of <mark> elements for annotation highlights |
remarkSourcePositions.ts |
src/lib/ |
Adds data-source-start/end attributes to map DOM → markdown offsets |
positions.ts |
src/lib/ |
Maps text nodes through formatted elements to correct markdown positions |
selection.ts |
src/lib/ |
Converts DOM selection ranges back to markdown character offsets |
export.ts |
src/lib/ |
Three export formats: annotated markdown, notes-only, raw |
github.ts |
src/lib/ |
GitHub blob URL parsing + raw content fetching + image resolution |
webpage.ts |
src/lib/ |
Webpage-to-markdown via Jina Reader proxy |
loadUrl.ts |
src/lib/ |
URL routing (GitHub vs. webpage) |
file.ts |
src/lib/ |
File validation for drag-drop |
types.ts |
src/lib/ |
Core types including Annotation |
useAnnotations.ts |
src/hooks/ |
Annotation CRUD, active ID tracking, offset shifting |
useTheme.ts |
src/hooks/ |
Dark/light mode with system preference detection |
useFileDrop.ts |
src/hooks/ |
Drag-and-drop handler |
useDragResize.ts |
src/hooks/ |
Sidebar width resize |
Current Annotation Type
interface Annotation {
id: string; // UUID
selectedText: string; // The highlighted text
note: string; // User's annotation
markdownStartOffset: number;
markdownEndOffset: number;
createdAt: number; // Timestamp
}
Current UI Components
App.tsx — Root layout, state management, view orchestration
DocumentView.tsx — Rendered markdown with selection, highlights, inline editing
Sidebar.tsx — Floating annotation cards positioned at mark Y coordinates
InputView.tsx — Landing page: URL input, textarea paste, drag-drop, changelog
ExportControls.tsx — Copy/download dropdown with 3 export modes
AnnotationPopover.tsx — Popover for creating annotations on text selection
NotePopover.tsx — Popover for viewing/editing existing annotations
RawView.tsx — Raw markdown source display
ThemeToggle.tsx — Dark/light mode switch
DropOverlay.tsx — Fullscreen overlay during file drag
Design System
- Fonts: Newsreader (serif for content), DM Sans (UI), JetBrains Mono (code)
- Colors: Warm paper (cream), ink tones, sienna accent, amber highlights
- Dark mode: Full custom dark variants
- All defined in
src/index.css as CSS custom properties + Tailwind config
Key Dependencies
react 19.2.0, react-dom 19.2.0
react-markdown 10.1.0
rehype-react 8.0.0
remark-gfm 4.0.1
unified 11.0.5
unist-util-visit 5.1.0
uuid 13.0.0
Tests
export.test.ts — Export format generation
github.test.ts — GitHub URL parsing
rehypeAnnotationMarks.test.ts — Highlight injection
loadUrl.test.ts — URL loading routing
webpage.test.ts — Webpage markdown conversion
worker/src/index.test.ts — Worker unit tests
Build Config
vite.config.ts — Base path /marginalia/ (GitHub Pages), build timestamp injection
tsconfig.json, tsconfig.app.json, tsconfig.node.json
eslint.config.js — ESLint 9 with TypeScript + React plugins
.github/workflows/deploy.yml — GitHub Actions → GitHub Pages
Decided Architecture for v2
Research Summary
Two rounds of deep research were conducted (initial + adversarial). Key findings:
-
Cloudflare D1 has real production reliability issues — 400ms+ latency on simple queries, single-writer bottleneck, primitive transaction model (no BEGIN TRANSACTION), documented data loss cases, and a multi-day degradation incident in July 2025. Viable for a single-user app but over-engineered for this use case.
-
Cloudflare is consolidating Pages → Workers. New projects (as of 2026) should deploy to Workers with the static-assets binding instead of Pages. Workers has full feature parity for static hosting + SSR, gets all future investment, and serving both the SPA and the API from one Worker eliminates CORS entirely. Pages remains supported but feature-frozen. Source: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
-
Custom OAuth is the highest-risk choice for a solo developer. Production-grade OAuth is 250-400 lines with many security pitfalls. Cloudflare Access eliminates this entirely for free (50-user limit).
-
Real-world note-taking apps (SilverBullet, Trilium, Joplin, Obsidian) overwhelmingly use local-first architectures. None use CF Pages + D1 + custom OAuth.
-
IndexedDB as primary database (not just a cache) is the right pattern for a single-user app. Instant writes, works offline, mature technology.
The Stack
┌─────────────────────────────────────────────────────────┐
│ Cloudflare Worker (marginalia-cloud) │
│ │
│ ┌──────────────────────┐ ┌─────────────────────────┐ │
│ │ Static assets │ │ /api/* routes │ │
│ │ (React SPA build) │ │ (sync ~50 lines) │ │
│ │ │ │ │ │
│ │ Dexie.js │ │ ┌─────┐ ┌──────────┐ │ │
│ │ (IndexedDB = │ │ │ R2 │ │ Access │ │ │
│ │ primary DB, │ │ │ │ │ (auth) │ │ │
│ │ in-browser) │ │ └─────┘ └──────────┘ │ │
│ └──────────────────────┘ └─────────────────────────┘ │
│ │
│ ONE origin — no CORS, Access cookies just work │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────┐
│ Cloudflare Worker │
│ (marginalia-reader, existing) │
│ Jina proxy, unauthenticated │
└─────────────────────────────────┘
| Layer |
Technology |
Role |
| Frontend |
React 19 + Vite 7 + Tailwind 4 |
Unchanged from v1 |
| Primary Database |
Dexie.js (IndexedDB) |
All reads/writes happen here. Auto-save is instant. Works offline. |
| Auth |
Cloudflare Access (free, 50 users) with GitHub as identity provider |
Zero auth code. Dashboard config only. Users see GitHub login screen. |
| Cloud Backup |
Cloudflare R2 (10GB free, S3-compatible) |
Worker receives JSON state export, stores in R2. Restore on new device. |
| API + Hosting |
One Cloudflare Worker w/ static assets binding |
Serves React build AND /api/sync routes from same origin. ~50 lines of API code. Sync protected by Access. |
| Existing proxy |
Keep separate (marginalia-reader Worker) |
Jina Reader CORS proxy for webpage-to-markdown. Unauthenticated; folding it in would force carving a public route through Access. |
| Deploy |
wrangler deploy (manual or via GitHub Actions / Workers Builds) |
No separate Pages deploy. |
Why This Stack
- 3 new technologies (Dexie.js, R2, Cloudflare Access) instead of 6 (D1, Hono, jose, Dexie, custom OAuth, service worker)
- Zero auth code — Cloudflare Access handles GitHub OAuth, session management, CSRF, token refresh, logout
- Instant persistence — IndexedDB writes are ~1ms, no network latency
- Offline by default — IndexedDB is the primary store, not a cache
- $0 total cost — Cloudflare free tier covers everything for a single user + up to 49 friends
- Clean upgrade path — If >50 users: swap Access for custom GitHub OAuth. If need real-time sync: add D1 or Durable Objects. If need collaboration: add Yjs + PartyServer.
Auth Details
- GitHub OAuth via Cloudflare Access — free, no code, users see standard GitHub login
- Why GitHub over Google: Target audience is developers who already paste GitHub URLs. GitHub OAuth has zero MAU limits, no verification process, 3-field setup. Google requires OAuth consent screen verification, privacy policy URL, domain verification.
- 50-user free limit on Access: Migration to custom GitHub OAuth is straightforward when needed — downstream code checks a JWT either way, only the token issuer changes.
- "Share with my notes" future feature: Access protects routes all-or-nothing, so shared/public documents would be served from a separate unprotected Worker route (e.g.,
/public/:shareId). This doesn't block the architecture.
Data Model (Dexie.js / IndexedDB)
// Database schema
const db = new Dexie('marginalia');
db.version(1).stores({
documents: 'id, title, updatedAt, createdAt',
annotations: 'id, documentId, createdAt',
versions: 'id, documentId, createdAt',
syncState: 'key' // last sync timestamp, etc.
});
interface Document {
id: string; // UUID
title: string;
content: string; // Full markdown
sourceUrl?: string; // If loaded from GitHub/webpage
tags: string[];
createdAt: string; // ISO date
updatedAt: string; // ISO date
}
interface Annotation {
id: string;
documentId: string;
selectedText: string;
note: string;
markdownStartOffset: number;
markdownEndOffset: number;
createdAt: string;
updatedAt: string;
}
interface DocumentVersion {
id: string;
documentId: string;
content: string; // Full markdown snapshot
createdAt: string;
}
- Annotations in separate store (not embedded in document) — independent CRUD, indexed lookups by document
- Version history: Last 5 full snapshots per document (start conservative, increase later)
- R2 backup format: Single JSON blob per user containing all documents + annotations + versions
Auto-Save Architecture
- Primary save: Write to IndexedDB via Dexie.js — instant, ~1ms
- Cloud backup: Debounced (every 5 minutes or on significant changes) JSON export → Worker → R2
- Immediate save on tab blur (
visibilitychange event) and beforeunload
- Multi-tab: Web Locks API for leader election if needed, or simply let each tab write to IndexedDB (last write wins — fine for single user)
- Save status UI: idle → saving → saved → error (React 19
useTransition)
- No CRDTs needed for single-user
Implementation Phases
Phase 1: New Repo + Ported Core Engine
- ✅ Create new repo (
marginalia-cloud) with Vite 8 + React 19 + TypeScript + Tailwind 4 + @cloudflare/vite-plugin + wrangler
- Port all core modules listed above (rehype plugins, selection, export, etc.) — likely interleaved with Phase 2 instead of done first
- Port all components
- Port tests
- Deploy to Cloudflare Workers (static assets binding, not Pages)
- Drop the
/marginalia/ base path (root path on the new domain)
Phase 2: Local Persistence with Dexie.js
- Add Dexie.js
- Create IndexedDB schema (documents, annotations, versions stores)
- Refactor
useAnnotations hook to read/write from Dexie instead of in-memory state
- Add document CRUD operations (create, rename, delete)
- Auto-save: annotations and edits persist to IndexedDB immediately
- Version snapshots: save to
versions store on significant changes (e.g., every 10 edits or 5-minute intervals)
Phase 3: Document Library Dashboard
- New landing page: list of saved documents (title, last modified date, created date)
- Sort by last modified (default), title, created date
- Search by title (client-side, Dexie query)
- Create new blank document
- Import from URL / paste / drag-drop → saves to library
- Delete document (with confirmation)
- The existing
InputView.tsx flow (paste/URL/drag-drop) should still work but now saves the result as a document
Phase 4: Cloud Backup via R2
- Set up Cloudflare Access with GitHub identity provider (dashboard config)
- Add
/api/sync routes to the existing Worker:
PUT /api/sync — receives full state JSON, writes to R2 as {userId}/state.json (userId derived from verified Access JWT, never client-supplied)
GET /api/sync — returns latest state JSON from R2 for the verified user
- Frontend sync logic: periodic export from Dexie → POST to same-origin
/api/sync
- Restore flow: on first load in new browser, check R2 for existing state, offer to hydrate IndexedDB
- Existing Jina proxy stays in
marginalia-reader Worker (separate deploy, unauthenticated)
Phase 5: Polish & Enhancements
- Tags/folders for document organization
- Client-side full-text search across documents (MiniSearch or Fuse.js)
- Version history UI (list snapshots, restore previous version)
- Save status indicator in header
- "Last synced" timestamp
- Banner on v1 repo pointing to v2
- Service Worker for offline app shell caching (vite-plugin-pwa) — optional
Upgrade Paths (When Complexity Is Justified)
| Trigger |
Action |
| >50 users |
Swap Cloudflare Access for custom GitHub OAuth (Hono + jose, ~250 lines) |
| Need server-side queries |
Add Cloudflare D1 alongside or replacing R2 JSON blobs |
| Multi-device real-time sync |
Add Durable Objects or PartyServer + WebSocket |
| Collaboration |
Add Yjs CRDTs via y-partyserver |
| Large documents (>1MB) |
Store content in R2, metadata in D1 |
| >100 documents per user |
Add D1 FTS5 full-text search server-side |
Cost Analysis
| Resource |
Free Limit |
Breaks At |
| Workers requests (incl. static asset serving) |
100K/day |
~100 daily active users |
| Workers Builds |
3,000 build min/month free |
~100 medium builds/month |
| R2 storage |
10 GB |
~20,000 large documents |
| R2 writes (Class A) |
1M/month |
~33K syncs/day |
| R2 reads (Class B) |
10M/month |
~333K restores/day |
| Cloudflare Access |
50 users |
50 users (then custom OAuth) |
Total cost: $0 for single user + friends. First paid tier is $5/month (Workers Paid) which provides massive headroom.
Risks & Gotchas
- IndexedDB storage limits: Browsers typically allow 50-80% of disk space. For a markdown app this is effectively unlimited, but Safari on iOS is more aggressive about evicting storage under pressure. Service Worker registration helps signal to the browser that storage is important.
- R2 backup is not incremental: Full state JSON means the sync payload grows with document count. Fine for <100 docs, may need chunking or D1 migration after that.
- Cloudflare Access free tier changes: Cloudflare has the most stable free tier track record (it drives their paid CDN/security business), but Heroku, PlanetScale, and others have killed free tiers. The architecture is portable — Dexie.js is browser-standard, R2 is S3-compatible.
- GDPR: If storing any user data server-side (even just GitHub user ID in R2 key), need a privacy policy. GitHub OAuth requires one. Keep it simple — store minimal data, implement "Delete Account" that wipes R2.
- Monitoring: Cloudflare Workers analytics (built-in) + Sentry free tier (5K errors/month) + UptimeRobot (50 monitors free).
References
Marginalia v2 — Full Architecture & Implementation Plan
Overview
Evolve Marginalia from a client-side-only markdown annotation tool into a persistent, authenticated experience — a "Google Docs for markdown annotation." This will be a new repository (
marginalia-cloud), porting the core rendering/annotation engine from the current repo. The current repo stays live on GitHub Pages with a banner pointing to the new version.What Exists Today (v1)
Tech Stack
https://marginalia-reader.galsapir.workers.dev)Core Engine (to be ported to v2)
These are the battle-tested modules that should be carried over:
rehypeAnnotationMarks.tssrc/lib/<mark>elements for annotation highlightsremarkSourcePositions.tssrc/lib/data-source-start/endattributes to map DOM → markdown offsetspositions.tssrc/lib/selection.tssrc/lib/export.tssrc/lib/github.tssrc/lib/webpage.tssrc/lib/loadUrl.tssrc/lib/file.tssrc/lib/types.tssrc/lib/AnnotationuseAnnotations.tssrc/hooks/useTheme.tssrc/hooks/useFileDrop.tssrc/hooks/useDragResize.tssrc/hooks/Current Annotation Type
Current UI Components
App.tsx— Root layout, state management, view orchestrationDocumentView.tsx— Rendered markdown with selection, highlights, inline editingSidebar.tsx— Floating annotation cards positioned at mark Y coordinatesInputView.tsx— Landing page: URL input, textarea paste, drag-drop, changelogExportControls.tsx— Copy/download dropdown with 3 export modesAnnotationPopover.tsx— Popover for creating annotations on text selectionNotePopover.tsx— Popover for viewing/editing existing annotationsRawView.tsx— Raw markdown source displayThemeToggle.tsx— Dark/light mode switchDropOverlay.tsx— Fullscreen overlay during file dragDesign System
src/index.cssas CSS custom properties + Tailwind configKey Dependencies
Tests
export.test.ts— Export format generationgithub.test.ts— GitHub URL parsingrehypeAnnotationMarks.test.ts— Highlight injectionloadUrl.test.ts— URL loading routingwebpage.test.ts— Webpage markdown conversionworker/src/index.test.ts— Worker unit testsBuild Config
vite.config.ts— Base path/marginalia/(GitHub Pages), build timestamp injectiontsconfig.json,tsconfig.app.json,tsconfig.node.jsoneslint.config.js— ESLint 9 with TypeScript + React plugins.github/workflows/deploy.yml— GitHub Actions → GitHub PagesDecided Architecture for v2
Research Summary
Two rounds of deep research were conducted (initial + adversarial). Key findings:
Cloudflare D1 has real production reliability issues — 400ms+ latency on simple queries, single-writer bottleneck, primitive transaction model (no
BEGIN TRANSACTION), documented data loss cases, and a multi-day degradation incident in July 2025. Viable for a single-user app but over-engineered for this use case.Cloudflare is consolidating Pages → Workers. New projects (as of 2026) should deploy to Workers with the static-assets binding instead of Pages. Workers has full feature parity for static hosting + SSR, gets all future investment, and serving both the SPA and the API from one Worker eliminates CORS entirely. Pages remains supported but feature-frozen. Source: https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/
Custom OAuth is the highest-risk choice for a solo developer. Production-grade OAuth is 250-400 lines with many security pitfalls. Cloudflare Access eliminates this entirely for free (50-user limit).
Real-world note-taking apps (SilverBullet, Trilium, Joplin, Obsidian) overwhelmingly use local-first architectures. None use CF Pages + D1 + custom OAuth.
IndexedDB as primary database (not just a cache) is the right pattern for a single-user app. Instant writes, works offline, mature technology.
The Stack
/api/syncroutes from same origin. ~50 lines of API code. Sync protected by Access.marginalia-readerWorker)wrangler deploy(manual or via GitHub Actions / Workers Builds)Why This Stack
Auth Details
/public/:shareId). This doesn't block the architecture.Data Model (Dexie.js / IndexedDB)
Auto-Save Architecture
visibilitychangeevent) andbeforeunloaduseTransition)Implementation Phases
Phase 1: New Repo + Ported Core Engine
marginalia-cloud) with Vite 8 + React 19 + TypeScript + Tailwind 4 +@cloudflare/vite-plugin+ wrangler/marginalia/base path (root path on the new domain)Phase 2: Local Persistence with Dexie.js
useAnnotationshook to read/write from Dexie instead of in-memory stateversionsstore on significant changes (e.g., every 10 edits or 5-minute intervals)Phase 3: Document Library Dashboard
InputView.tsxflow (paste/URL/drag-drop) should still work but now saves the result as a documentPhase 4: Cloud Backup via R2
/api/syncroutes to the existing Worker:PUT /api/sync— receives full state JSON, writes to R2 as{userId}/state.json(userId derived from verified Access JWT, never client-supplied)GET /api/sync— returns latest state JSON from R2 for the verified user/api/syncmarginalia-readerWorker (separate deploy, unauthenticated)Phase 5: Polish & Enhancements
Upgrade Paths (When Complexity Is Justified)
Cost Analysis
Total cost: $0 for single user + friends. First paid tier is $5/month (Workers Paid) which provides massive headroom.
Risks & Gotchas
References
useLiveQuery)