29 Jul 2026
Tumblr, one image at a time
The Tumblr dashboard is an infinite scroll, which means two things. You never know whether you've already seen something, and you never reach the end. Both are deliberate, and both are the reason a browsing session ends in vague fatigue rather than a feeling of being finished.
This is the opposite of that. One image, full screen, arrow keys to move. Everything you've ever seen is remembered permanently, so nothing comes round twice. And a progress bar shows how much is left, because a session that can end is a different experience from one that can't.
Mouse input isn't supported. That isn't minimalism for its own sake — the whole interaction is one hand on the arrow keys, and adding a second input would have meant designing for both.
It lives at tumblr-keyboard.vercel.app, though it's wired to my account and my dashboard, so there's nothing there to see unless you're me.
Frames, not posts
A post is the wrong unit. A photoset of six images is six things to look at, so the app walks each post's content blocks and treats every image block as its own frame. Videos are discarded, text posts never enter the pool, GIFs autoplay.
Identity is the image's media key, not the post's ID, which is what makes the memory work: the same picture reblogged forty times by forty people is one thing you've seen, once, forever. That table never gets pruned.
The end is a real number
On load, the app pages backwards from the top of the dashboard to wherever the last session stopped, and counts the unseen frames. That count freezes and becomes the denominator of the progress bar, so the bar can't grow while you're moving through it — which is the specific way infinite feeds break the sense of progress.
New posts still arrive during a session. They're counted quietly in the background and shown as a +N tick; when the bar empties, they roll into the next lap. If the backlog scan hits its cap the bar gets a frayed right edge instead of a made-up number.
Reblogging is one keypress. It publishes immediately, copies the source tags minus a denylist, and Z undoes the last one within a few seconds — the pattern where you commit first and take it back if you were wrong, rather than confirming every time.
The stack
Next.js on Vercel, Postgres on Neon, and the Tumblr API over OAuth 2.0.
- Every Tumblr call goes through a server route. Not an architectural preference — the Tumblr API doesn't send CORS headers, so the browser can't talk to it directly at all.
- OAuth tokens live in the database, not in environment variables. The grant is done once by hand; after that the app only ever refreshes them, and the deployed copy and the local one share the same row.
- Seen-state is batched, written every twenty frames and flushed when the tab is hidden or closed, so browsing doesn't mean a database write per keystroke. Key repeat is ignored, so holding an arrow advances exactly one frame instead of sixty.
- Failure has a defined shape. Rate limits back off behind a small indicator, an expired token refreshes once and then stops, and a scan that fails falls back to an unbounded session with no progress bar rather than a wrong one.
It's a single-user app. Nobody else logs in, which removed most of the questions a project like this normally has to answer.