I Built a Chrome Extension in a Weekend — Here’s What I Learned (and What I’d Do Differently)

Published July 7, 2026 by admin

Behind the Build

Author: Balaji R (senpai) Published: July 2026 Reading time: ~12 min Tags: extractdb, building-in-public, indie-hacker, chrome-extension, founder-story, behind-the-build, technical-deep-dive

Overview

Every maker knows the feeling: you’re deep in a workflow, doing something repetitive, and you think — there has to be a better way.

For me, that moment came while copying tables from ChatGPT into Airtable. For the third time that day. Each time, the formatting broke, columns didn’t align, and I lost data in the shuffle.

So I built ExtractDB — a Chrome extension that exports AI chat tables to Airtable, Google Sheets, and Notion in one click.

This is the full story. Not the sanitized “I built it in a weekend and it was magical” version. The real one — including the decisions I still second-guess, the bugs that made me question my life choices, and the architectural gotchas that Chrome extension developers rarely talk about until you’ve been burned by them.

If you’re an indie hacker thinking about building a Chrome extension, or just someone curious about what happens after “ship fast” — this one’s for you.


The Itch — Deeper Than I Thought

I live in AI chats. As an indie builder, I use ChatGPT, Claude, and Perplexity daily for:

  • Content calendars and editorial plans
  • Task lists and project milestones
  • Lead research and competitor analysis
  • Meeting notes and client deliverables

The AI would generate beautiful, structured tables. Then I’d spend 15 minutes trying to get that data into my actual tools. Copy. Paste. Fix. Realign. Repeat.

But here’s the thing — I didn’t jump straight to building. I spent about two weeks trying to solve this without building my own tool. I evaluated:

  • Zapier — Felt like bringing a flamethrower to light a candle. I didn’t need a workflow automation platform; I needed a single button. Plus, setting up Zaps for one-off table exports was slower than copy-pasting.
  • Thunderbird (before it was Thunderbit) — Close, but it generalized too much. It tried to be a “copilot for everything” and the AI table export was buried inside a broader feature set.
  • Manual automation scripts — I wrote a quick Python script using Selenium to scrape ChatGPT and push to Airtable’s API. It worked. Once. Then ChatGPT’s DOM changed and my selectors broke. Maintaining scraper scripts is a nightmare you don’t want.
  • Just getting better at copy-pasting — I genuinely tried. I learned keyboard shortcuts, tried different paste methods (plain text paste, paste special, paste with destination formatting). Nothing worked reliably.

The itch wasn’t just annoyance — it was a gap I couldn’t solve with existing tools.


The Setup: Decisions Before Code

Before I wrote a single line of code, I had to make a few foundational decisions. These seem trivial in hindsight, but they shaped everything that followed.

Chrome Extension vs Web App vs Desktop App

I considered three approaches:

Desktop app (Electron/Tauri): Off the table immediately. I didn’t want users to install an entire desktop application just to export a table once a day. The friction would kill adoption.

Web app: Interesting for a moment. Users would paste their chat content into a web interface, and we’d parse and export it. But that introduces a server — suddenly I’m responsible for storing (even temporarily) people’s AI conversations. That’s a privacy liability I didn’t want. Also, the UX sucks. Nobody wants to copy text, open a new tab, paste it, click export, and then go back to their chat.

Chrome extension: Won by a mile. It lives where the user already is — inside the chat interface. One click, done. No context switching. And because everything runs client-side, there’s no server to manage, no data to store, no compliance paperwork. Privacy is baked into the architecture by default.

JavaScript vs TypeScript

I went with vanilla JavaScript. Controversial, I know. Here’s why:

  • This was a weekend project. TypeScript adds compile step overhead, configuration complexity, and mental friction when you’re in flow state on a Saturday morning.
  • The Chrome extension API surface is relatively small. Most of what I was touching — chrome.tabs, chrome.runtime, chrome.storage, chrome.identity — has excellent documentation and predictable return types. TypeScript would have been nice-to-have, not need-to-have.
  • I knew I’d rewrite parts anyway. The first version’s job was to validate the core loop, not to have perfect type coverage.

Would I use TypeScript if I started today? Probably. The extension has grown enough that the lack of types creates real maintenance drag. But for that first weekend? Vanilla JS was the right call.

What I Already Had on the Shelf

I didn’t start from zero. I had:

  • An Airtable API key I’d been using for a personal project
  • A Chrome extension starter template I’d modified from a previous (failed) side project
  • About two years of React experience that I deliberately didn’t use (more on this in the technical section)

The Weekend Build — Technical Deep Dive

Day 1 (Saturday): Core Architecture

I started Saturday morning at 8 AM with coffee and a rough plan. By 10 PM, I had a working prototype. Here’s what that day looked like.

Content Script Injection

The foundation of any Chrome extension that interacts with a web page is the content script. I declared mine in manifest.json to run on https://chatgpt.com/ and https://claude.ai/:

{
  "content_scripts": [{
    "matches": [
      "https://chatgpt.com/*",
      "https://claude.ai/*",
      "https://www.perplexity.ai/*"
    ],
    "js": ["content.js"],
    "run_at": "document_idle"
  }]
}

document_idle was important — it ensures the page is fully loaded before my script runs, but doesn’t block page rendering. For a utility script like this, it’s the sweet spot between reliability and performance.

DOM Traversal for Table Detection

The core loop is deceptively simple on paper:

  1. Walk the DOM and find elements
  2. Extract header rows and data rows
  3. Serialize to a structured format (JSON → Airtable schema)
  4. Present detected tables to the user for selection
  5. One-click export
  6. The reality: ChatGPT doesn’t render HTML

    elements. It uses a complex nest of
    , flexbox, and CSS grid to simulate tables. The DOM structure changes between ChatGPT versions, between Claude and Gemini, and even between different response formats on the same platform.

    My table detection logic started as a function called findTables() that recursively walks the DOM and heuristically identifies tabular structures. The heuristic: find elements with three or more child elements that have uniform dimensions and a grid-like layout.

    function findTables(root) {
      const candidates = [];
      const walker = document.createTreeWalker(
        root,
        NodeFilter.SHOW_ELEMENT,
        null,
        false
      );
      
      let node;
      while (node = walker.nextNode()) {
        // Heuristic: look for containers with regularly spaced children
        if (isGridContainer(node)) {
          const rows = extractRows(node);
          if (rows.length >= 2) { // header + at least one data row
            candidates.push({ element: node, rows });
          }
        }
      }
      
      return candidates;
    }

    This was naïve. It worked for simple cases but failed on AI chats with complex formatting. I iterated on this heuristic about fifteen times in the first day.

    The MutationObserver Nightmare

    Here’s where things got spicy.

    AI chat responses stream in token by token. ChatGPT’s interface builds the response DOM incrementally. By the time document_idle fires, there’s no table to detect — the assistant hasn’t started responding yet.

    I needed a MutationObserver that watches for structural changes to the DOM and re-runs table detection after each meaningful mutation:

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          debouncedDetect(mutation.target);
        }
      }
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });

    The debounce was critical. Without it, every keystroke in the user’s prompt triggered table detection. With a 500ms debounce, detection only fires after the DOM has settled.

    Even then, there were edge cases. If the user scrolled while a table was streaming, the MutationObserver might fire while the DOM was in an inconsistent state. I added a requestAnimationFrame wrapper to ensure detection only runs after the browser has painted the latest DOM state:

    function debouncedDetect(target) {
      if (detectTimer) clearTimeout(detectTimer);
      detectTimer = setTimeout(() => {
        requestAnimationFrame(() => {
          const tables = findTables(document.body);
          updatePopupState(tables);
        });
      }, 500);
    }

    CSP Issues with Inline Scripts

    Chrome extensions have strict Content Security Policy by default. Inline scripts (