Letterboxd
-
What I Learned Building My First Chrome Extension
I built a Chrome extension to navigate Letterboxd movie lists with keyboard shortcuts. Rate, like, watch, next. Here’s what I learned.
The Idea
I going through the Letterboxd lists, but wanted a better way. “Top 250 Films,” curated genre lists, friends' recommendations. The flow becomes tedious: click a movie, rate it, go back to the list, find where you were, click the next one. I wanted to load a list into a queue and step through movies one by one with keyboard shortcuts.
So I did what any reasonable person would do and built a Chrome extension for it.
The Framework Graveyard
The Chrome extension ecosystem has a framework problem. CRXJS, the most popular Vite plugin for extensions, was being archived. Its successor, vite-plugin-web-extension, was deprecated in favor of WXT. WXT is solid but it’s another abstraction layer that could go the same way.
I went with plain Vite and manual configuration. Four separate Vite configs, one per entry point (content script, background worker, popup, manager page). A simple build script that runs them sequentially and copies the manifest. No framework dependency that could die on me.
For the UI I used React and TypeScript. Not because the extension needed React, most of the work is content scripts and background messaging, but the popup and settings page benefit from component structure.
Four Separate Worlds
One thing I learned was a Chrome extension isn’t one app. It’s four separate JavaScript contexts that can’t directly share state:
- Content scripts run on the webpage (letterboxd.com). They can read and modify the DOM but can’t access chrome.tabs or other extension APIs.
- Background service worker runs independently. It handles messaging, storage, and tab navigation. It can die at any time and restart.
- Popup is a tiny React app that opens and closes with the extension icon. It loses all state when closed.
- Extension page (the manager) is a full tab running your own HTML. It persists as long as the tab is open.
They communicate through
chrome.runtime.sendMessageandchrome.storage.local. This is an important architectural challenge you need to be aware of. If you’ve never built an extension before, it could trip you up.Letterboxd’s DOM Is a Moving Target
The existing open-source Letterboxd Shortcuts extension uses selectors like
.ajax-click-action.-liketo click the like button. Those selectors don’t exist anymore. Letterboxd has migrated to React components, and the sidebar buttons (watch, like, watchlist, rate) are loaded asynchronously via CSI (Client Side Includes). They’re not in the initial HTML at all.I had to inspect the actual loaded DOM to find the current selectors:
.watch-link,.like-link,a.action.-watchlist. The rating widget still uses the old.rateitpattern with adata-rate-actionattribute and CSRF token POST.If you’re building an extension that interacts with a third-party site’s DOM, expect the selectors to break. Build your DOM interaction layer as a thin, isolated module so you can update selectors without touching the rest of the codebase.
Service Workers Can’t Use DOMParser
My list scraper used
DOMParserto parse HTML responses. Works fine in tests (jsdom), works fine in content scripts (browser context), fails completely in the background service worker. Service workers don’t have access to DOM APIs.I rewrote the parser to use regex. Less elegant but it works everywhere. If I were doing it again, I’d run the parsing in a content script and message the results back to the background worker.
The Build System Is Simpler Than You Think
I expected the multi-entry-point build to be painful. It wasn’t. Each Vite config is about 20 lines. Content script and background worker build as IIFE (single file, no imports). Popup and manager build as standard React apps. The build script is 30 lines of
execFileSynccalls.One gotcha: asset paths. Vite defaults to absolute paths (
/assets/index.js), but extension popups and pages need relative paths (./assets/index.js). Addingbase: './'to the popup and manager configs fixed it.TDD Was Worth It (For the Right Parts)
The extension has four pure logic modules: rating double-tap behavior, auto-advance detection, queue state operations, and keyboard shortcut matching. These are the core of the extension and they’re completely testable without a browser.
Writing tests first caught edge cases I wouldn’t have thought of. What happens when you press the same rating key on a movie that was already rated in a previous session? What if the queue is empty and someone hits “next”? The tests document these decisions.
For DOM interaction code, the Letterboxd API layer, overlays, CSI-loaded content, unit testing isn’t practical. I tested those manually.
What I’d Do Differently … or might change
Start with the DOM. I built the pure logic first and the DOM interaction last. This meant I didn’t discover the CSI loading issue, the changed selectors, or the DOMParser problem until the end. Next time I’d build a minimal content script first, verify it can interact with the target site, then build the logic on top.
Use fewer Vite configs. Four config files with duplicated path aliases is annoying. A single config with a build mode flag, or a shared config factory function, would be cleaner.
Consider the popup lifecycle earlier. Popups close when you click outside them. Any state they hold is gone. I designed around this (the popup is stateless, it queries the background on every open), but it’s easy to get wrong if you don’t plan for it.
The Result
The extension loads any Letterboxd list into a queue, navigates through movies one by one, and lets me rate/like/watch/watchlist with single keystrokes. Auto-advance moves to the next movie when I’ve completed my actions. A dark-themed manager page shows the full queue and lets me customize every shortcut.
It’s a personal tool right now, so not published to the Chrome Web Store. But it’s made going through movie lists is pretty cool. Sometimes the best software is the kind you build for yourself!
If you’re a developer, I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week. Or you can find me on Mastodon at @[email protected].