So I never leave Instagram
February 16, 2026 | Seattle 🌧️
The single most tedious part of running the Trad Directory has always been the grind of adding artists. I find someone great on Instagram, then I have to leave Instagram, go to my admin tool, retype their handle, their name, their city, their shop. The context switch is small but I do it hundreds of times, and hundreds of small frictions is just… friction.
So I stopped fighting it and put the directory inside Instagram. Now when I’m on an artist’s profile I hit Alt+T, a panel slides out of the side of Chrome, and it already knows who I’m looking at. Check if they’re in the directory, add them if not, done — without the tab ever changing. It’s a Chrome extension, it’s genuinely changed how I use the whole project, and it taught me some things about Manifest V3 that I did not expect to care about.
Three little programs that can’t talk to each other
The thing nobody tells you about Chrome extensions is that they aren’t one program. They’re several, running in isolated worlds that are deliberately not allowed to call each other’s functions. For this extension there are three:
- A content script that runs on the Instagram page and can read its URL.
- A service worker in the background with no UI at all.
- The side panel — the actual UI you see, which is just a webpage in a sidebar.
The content script knows the handle. The side panel needs the handle. And they cannot talk to each other directly. Everything goes through message passing, with the service worker as a relay in the middle. Once you accept that constraint the whole architecture falls out of it.
Watching a page that never reloads
The content script’s job is tiny: pull the handle out of the URL and announce it. Instagram profile URLs are just instagram.com/<handle>, so it’s a one-line regex. But there’s a catch — Instagram is a single-page app, so clicking to a different profile never triggers a page load. The content script only runs once, on the first load, and then the URL changes under it silently.
The fix is a slightly gross but effective hack: watch the entire page body for any DOM change, and each time, just check whether the URL string moved:
function getHandleFromURL() {
const path = window.location.pathname;
const match = path.match(/^\/([a-zA-Z0-9._]+)\/?$/);
return match ? match[1] : null;
}
function sendHandle() {
const handle = getHandleFromURL();
if (handle) chrome.runtime.sendMessage({ type: "HANDLE_DETECTED", handle });
}
sendHandle();
// Re-detect on URL changes (Instagram is a SPA).
let lastURL = window.location.href;
const observer = new MutationObserver(() => {
if (window.location.href !== lastURL) {
lastURL = window.location.href;
sendHandle();
}
});
observer.observe(document.body, { childList: true, subtree: true });
That MutationObserver isn’t watching for anything meaningful in the DOM — it fires on every mutation and just does a cheap string compare on the URL. Coarse, but it costs nothing and it means switching profiles updates the panel instantly. The regex only matches single-segment URLs on purpose, so /p/, /reels/, /explore/ correctly resolve to “no handle here.”
The service worker is the relay, and it’s almost comically simple — it re-broadcasts the message and swallows the error you get when the panel isn’t open:
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "HANDLE_DETECTED") {
chrome.runtime.sendMessage({ type: "HANDLE_DETECTED", handle: message.handle })
.catch(() => { /* Side panel may not be open yet — ignore */ });
}
});
And the panel closes the loop by dropping the handle into its input. Instagram tab → service worker → side panel, all over chrome.runtime. That’s the whole extension, and the first version came together over a couple of days in February — enough to stand on a profile and add an artist without leaving the tab.
Reads go to the database, writes go through the front door
The first cut was scrappy about how it talked to the data. The real version — once I sat down and properly linked up the two codebases a few months later — has a split personality on purpose. Reads (“is this handle already in the directory,” search, pagination) go straight to Supabase’s REST API with a read-only key. Fast, no middleman. But writes deliberately do not touch the database directly. They go through the main web app’s serverless endpoints, and the comment says exactly why:
// Admin API layer — writes that MUST go through the web app's endpoints so
// slug generation, geocoding, and artist_location dual-write happen. Uses
// ADMIN_API_KEY; a direct Supabase PATCH/DELETE would skip all of that.
async function apiRequest(method, endpoint, body) {
const res = await fetch(`${API_BASE_URL}/api/${endpoint}`, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ADMIN_API_KEY}` },
body: JSON.stringify(body),
});
If the extension wrote to the database itself, it’d have to re-implement slug generation, geocoding, and a pile of invariants the server owns — and it’d drift out of sync the first time I changed any of that logic. Routing writes through the real API means the server stays the one place that knows the rules.
Two repos, one contract, and a hook that yells at me
That decision created a new problem, and it’s the one I find most interesting in hindsight. The extension lives in one repo. The API lives in another. They share no code and no build step — the extension is literally just files you load unpacked. So if I rename an API endpoint, or change which HTTP method it accepts, the extension keeps calling the old one and silently 404s. Nothing catches it until I’m standing on an Instagram profile wondering why “Add” does nothing.
It took a couple of those silent breakages before I got fed up and wrote a checker that runs before every push. It scrapes every API call out of the extension’s code — both the ways it makes them — captures the HTTP method, and cross-references them against the actual handler files in the API repo:
// Form A: apiRequest("METHOD", "endpoint", …)
for (const m of client.matchAll(/apiRequest\(\s*["']([A-Z]+)["']\s*,\s*["']([A-Za-z0-9_]+)["']/g)) {
calls.push({ endpoint: m[2], method: m[1] });
}
// Form B: literal fetch to `${API_BASE_URL}/api/<name>`; method is in the options object right after.
for (const m of client.matchAll(/`\$\{API_BASE_URL\}\/api\/([A-Za-z0-9_]+)`/g)) {
const win = client.slice(m.index, m.index + 300);
const mm = win.match(/method:\s*["']([A-Z]+)["']/);
calls.push({ endpoint: m[1], method: mm ? mm[1] : null });
}
It figures out what method each handler accepts by reading the one convention the API repo actually follows — every handler guards with a single if (req.method !== "X") and 405s otherwise. Then it fails on the two poison pills: an endpoint that doesn’t exist, or a method the handler will reject:
for (const c of broken) {
fail(`client calls ${c.method} /api/${c.endpoint} — no handler ${c.endpoint}.ts in the API repo`);
exitCode = 1;
}
for (const c of mismatched) {
fail(`client calls ${c.method} /api/${c.endpoint} but the handler only allows ${handlerMethod[c.endpoint]} (405)`);
exitCode = 1;
}
That runs from a pre-push git hook. It skips cleanly if the sibling repo isn’t checked out (so a fresh clone or CI doesn’t choke), and otherwise blocks the push with a message telling me to fix both sides together:
if ! node "$root/scripts/check-api-sync.js"; then
echo "api-sync: check failed — push blocked. Update the API + client together." >&2
exit 1
fi
The first version of that checker only understood one of the two call forms, so half my writes were invisible to it and a rename would sail through green. Hardening it to parse both forms and the method is the difference between a gate and a decorative gate.
The honest footnotes
A couple things I’d flag if you were thinking of copying this. The extension is Manifest V3, version 0.1.0, and it is not on the Chrome Web Store — I load it unpacked. It’s a real, in-daily-use tool, but it’s mine, singular. Auth is a single shared admin key sitting in a gitignored config file, which is completely fine for a one-operator internal tool and completely unacceptable for anything public. And the README still cheerfully claims the data layer is a stub that just console.logs — it’s been a full-blown API client for most of a year. The docs lie; the code doesn’t. Solo projects are like that.
But it works, and it turned the most tedious part of this whole thing into a two-keystroke reflex. The directory now grows fastest when I’m just… browsing Instagram like I already was. Which was the entire point.
Poke at the directory it feeds: www.trad-directory.com.
What was spinning while writing:
Get Got by Death Grips