Threadly
Semantic search over an infinite note canvas
- role
- Solo, design through deploy
- when
- 2025
- loc
- 3,066
- files
- 50
- Next.js 16
- React 19
- TypeScript
- Tailwind v4
- Supabase
- Postgres
- pgvector
- OpenAI embeddings
- Zustand
By the numbers
The problem with a notes app
Every notes app makes you do the filing. Tags, folders, backlinks, all of it manual, and all of it work you do now for a benefit you might get later. Most people stop filing within a week, and then the notes are just a pile.
Threadly does the filing with embeddings. Write a note, and it finds the other notes it belongs near. No tags, no folders, nothing to maintain.
Retrieval, concretely
Every note is embedded on write with OpenAI text-embedding-3-small, and the vector is stored on
the row in Postgres with pgvector.
The part I like is what happens on read. To find notes related to note N, the endpoint does not
re-embed anything. It reads N's stored embedding straight off the row and uses that as the query
vector. Finding neighbours costs one database round trip and zero API calls.
The nearest-neighbour search is a Postgres function called over RPC:
const { data: neighbors } = await supabase.rpc("match_notes", {
query_embedding: embedding,
match_count: 6,
});
const results = (neighbors ?? [])
.filter((r) => r.id !== id) // a note is always its own nearest neighbour
.map((r) => ({ id: r.id, score: 1 - r.distance, distance: r.distance }))
.slice(0, 5);match_count is 6 rather than 5 on purpose. A note is always its own closest match at distance 0, so
asking for six and dropping self leaves exactly five real neighbours. Asking for five would have
quietly returned four.
Distance is converted to a similarity score with 1 - distance before it crosses the API boundary,
so the client never has to know that smaller means closer.
The canvas
Notes live on an infinite canvas rather than in a list, which meant writing the camera by hand:
a { x, y } offset plus a scale, and conversion in both directions between screen space and world
space.
const worldX = (screenX - camera.x) / scale;Pinch-to-zoom is Math.hypot on the delta between two active touch points, compared frame to frame.
Two fingers moving apart is a ratio greater than one, which becomes the scale multiplier.
Notes connect to each other with edges, and the connection endpoints are typed rather than positional:
type AnchorSide = "top" | "right" | "bottom" | "left";Persisting from_side and to_side alongside from_id and to_id means an edge remembers which
face of the note it left from. Move the note and the curve re-renders from the correct side instead
of snapping to the nearest one and flipping around as you drag.
Things I did not skip
Auth runs through Supabase with Google OAuth and email. The route guard calls getUser() rather
than getSession(). getSession() reads the JWT out of the cookie and believes it; getUser()
revalidates against the auth server. The second one is a network call on every guarded request and
it is the correct choice.
Response headers are set rather than defaulted: HSTS with preload and a two year max-age, CSP
frame-ancestors 'none', X-Frame-Options: DENY, cross-origin opener policy set to same-origin,
and a permissions policy that denies camera, microphone and geolocation outright. None of it is
interesting, all of it is the difference between a side project and something you would put a login
on.
What I would change
The embedding happens synchronously on write, which is fine at the current size and would not be at ten times it. The right shape is a queue: write the note immediately, embed out of band, backfill the vector. Right now a slow OpenAI response is a slow save.
There is also no index tuning on the vector column yet. At this corpus size a sequential scan is genuinely faster than an approximate index, so adding one now would make it slower, not faster. It becomes worth doing somewhere in the tens of thousands of notes.