I write a lot in Notion, but getting a thought into Notion was always slower than the thought itself. Switch apps, find the page, wait for it to load, and the idea is gone.
So I built Notion Quick Capture: a tray app for Windows, macOS and Linux. Press Ctrl+Shift+N anywhere, type, press Ctrl+Enter, and the note lands in a Notion page or database.
This post covers the parts that turned out to be more interesting than I expected.
The architecture in one picture
src/
├── main/ Electron main process
│ ├── auth/ Token storage (safeStorage)
│ ├── notion/ API client + rate limiting, schema cache, search, capture
│ ├── drafts/ Offline queue (sql.js) and background sync
│ ├── ipc/ IPC handlers (Zod-validated)
│ ├── hotkey/ Global shortcut
│ └── tray/ Tray icon and menu
├── preload/ contextBridge API exposed to the renderer
├── renderer/ Vue 3 UI
└── shared/ Types and the Markdown → Notion converter
The renderer never touches the network or the file system. It asks the main process for everything through a small, typed contextBridge API.
1. Markdown in, native Notion blocks out
Notion's API doesn't accept Markdown. It wants a tree of block objects (heading_2, bulleted_list_item, to_do, code, table…) with rich-text arrays for inline formatting.
The editor is Milkdown, so you get live formatting while you type (# becomes a heading, [] becomes a to-do, / opens a block menu). On save, the Markdown goes through markdown-it. I walk its token stream and build Notion blocks: nested lists become children, bold/italic/strikethrough/code/links become rich-text annotations, and tables become table + table_row blocks.
The converter lives in src/shared/ and is pure TypeScript with no Electron dependency, which makes it easy to unit test with Vitest.
2. Database-aware forms
If your destination is a database, the app fetches its schema and renders a field for each property: select, multi-select, status, date, people, relation, checkbox, number, URL, email, phone, files. Schemas are cached, so the panel opens instantly the next time.
3. Offline first, with no native modules
Captures go into a local queue before they're sent. If you're offline, or Notion returns a 5xx or rate-limit error, the note stays queued and a background sync retries later. Captures that can never succeed (say the destination was deleted) are surfaced in the tray menu so you can discard them.
For storage I used sql.js, SQLite compiled to WebAssembly. That means no better-sqlite3, no node-gyp, and no "please install Visual Studio Build Tools" for contributors. npm install && npm run dev works the same on all three OSes.
4. Security: a small app still holds a powerful token
A Notion integration token can read and write whatever you've shared with it, so I treated it accordingly:
- The token is encrypted with Electron's
safeStorage: DPAPI on Windows, Keychain on macOS, libsecret on Linux. It's never logged. - Renderer windows are sandboxed, use context isolation, have a strict CSP, and have no Node.js access.
- Every IPC message is validated with Zod in the main process. The renderer is treated as untrusted input.
- The only hosts the app talks to are
api.notion.comand GitHub Releases (for updates). No telemetry.
5. Why there's no "Sign in with Notion" button
OAuth would be friendlier than asking users to create an internal integration. But Notion's OAuth flow needs a client secret, and anything shipped inside a desktop binary that anyone can download isn't secret. Rather than pretend otherwise, public builds use integration tokens, and I wrote a step-by-step guide with screenshots. OAuth is still in the code for anyone running a private build with their own public integration.
6. Cross-platform CI, and two bugs that only happened on Linux and macOS
GitHub Actions runs lint, typecheck, unit tests and UI smoke tests on Windows, macOS and Linux for every push, and builds installers for all three. The smoke tests launch the real Electron app against a stubbed Notion API, so no token is needed.
Windows was green from the start. Linux and macOS weren't, and the causes were worth writing down:
-
ELECTRON_RUN_AS_NODEwas set in the test environment. When it is, Electron starts as plain Node.js,require('electron')doesn't give you theappAPI, and you get a confusingCannot read properties of undefined (reading 'on'). Explicitly unsetting it for the test run fixed it. -
Offscreen windows get throttled. The tests render offscreen, and Chromium throttles background windows, so CSS transitions and timers stalled until the tests timed out. Setting
webPreferences.backgroundThrottling: falseon the test windows fixed it.
What's next
- Code signing for Windows and macOS (current builds trigger SmartScreen/Gatekeeper warnings)
- More capture sources, such as sending the current clipboard or selected text
The project is MIT licensed and contributions are welcome:
👉 Website: https://notioncapture.somaraj.com
👉 Source: https://github.com/somaraj/notion-quick-capture
If you try it, I'd love to hear what breaks and what's missing.
Not affiliated with or endorsed by Notion Labs, Inc.

Top comments (0)