Skip to main content

Command Palette

Search for a command to run...

Building a Chrome Extension That Bridges Three SaaS Tools

What it actually takes to inject yourself into someone else's app — content scripts, message passing, OAuth across three providers, and the DOM-shift bugs nobody warns you about.

Updated
9 min read
H
An engineer with over 8+years of experience in crafting applications.

Building a Chrome Extension That Bridges Three SaaS Tools

Chrome extensions live in a strange place. They're not quite apps. They're not quite scripts. They run in a browser context, talk to a backend, and inject themselves into pages written by people who never imagined you'd be there.

I built one that lived inside HubSpot and SalesLoft, the two CRMs used by sales development teams, and integrated them with our own platform's API. Hundreds of SDRs used it every day to automate parts of their gifting workflow. The extension itself was small. The integration work that made it useful was the actual project.

This is a tour of what that architecture looked like, the parts of Chrome extension development that are genuinely hard, and the parts that look hard but aren't.

The integration problem

The SDR's day looks something like this:

  1. They're working in HubSpot, looking at a contact's profile.

  2. They want to send that contact a gift via our platform.

  3. Without the extension, they'd: copy the contact's email, switch tabs to our platform, search for the contact, pick a gift, hit send. About 90 seconds.

  4. With the extension, they: click a button injected into the HubSpot page, pick a gift from a dropdown, hit send. About 12 seconds.

Multiply by 100 contacts per day. Hundreds of SDRs. That's the workflow win.

The extension has to:

  • Detect when the user is on a relevant HubSpot or SalesLoft page.

  • Inject a UI element that fits the host page visually.

  • Read the contact information from the host page.

  • Talk to our backend to send the gift.

  • Update the host page to reflect what was sent.

Each of these has a wrinkle.

Manifest v3 reality check

If you're starting fresh in 2026, you're on Manifest v3. The big differences from v2:

  • No persistent background page. Background pages are now "service workers" that wake up on events and go to sleep when idle. Persistent state has to live in chrome.storage, not in module-level variables.

  • No remote code execution. You can't load <script src="https://your-cdn/extension-code.js"> anymore. All scripts have to ship in the extension bundle.

  • Stricter content security. Inline <script> tags are dead. Same with eval-style patterns.

For most extensions, this is a tightening of practices that were already advisable. For some legacy extensions, it was a major rewrite. Plan for the new restrictions if you're upgrading.

The architecture

Four pieces:

  1. Background service worker. Receives messages from content scripts, calls our API, handles auth state.

  2. Content scripts. Injected into HubSpot and SalesLoft pages. Detect what page the user is on, find DOM elements to attach to, render the UI.

  3. Popup. The user clicks the extension icon to get a small popup. Used mostly for login and settings.

  4. Options page. A full HTML page with extension settings.

The content scripts do the host-specific work. The background worker does the cross-host logic and API calls. Communication happens via chrome.runtime.sendMessage.

// content-script.ts
const response = await chrome.runtime.sendMessage({
  type: 'send-gift',
  data: {
    recipientEmail: 'alice@customer.com',
    giftId: 'gift_42',
  },
})
// background.ts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'send-gift') {
    sendGift(msg.data).then(sendResponse).catch(err => sendResponse({ error: err.message }))
    return true  // async response
  }
})

The return true is mandatory. Without it, the message channel closes before your async response can be sent. This is the single most common Chrome extension bug I've seen.

Reading the host page

This is the part that's specific to your integration. You have to figure out what page the user is on (e.g., "HubSpot contact detail") and where the data you need lives in the DOM.

The trick is that the host page is someone else's app. They can change the DOM at any time. Your selectors will break.

Three patterns that helped:

Use data attributes when they exist. Many SaaS apps have data-testid="..." or similar attributes for their own E2E tests. These are more stable than CSS class names. We had a content script that used data-test-id="email-display" to find the contact's email, and that selector lasted three years.

Have a fallback chain. When the data attribute doesn't exist, fall back to a CSS selector. When that breaks, fall back to a textContent search. Log which path was used, so when the primary path breaks (it will), you know.

function findContactEmail(): string | null {
  return (
    document.querySelector('[data-test-id="email-display"]')?.textContent?.trim() ||
    document.querySelector('.contact-email')?.textContent?.trim() ||
    findByLabel('Email')?.trim() ||
    null
  )
}

Build a "host probe" tool. This is a dev-mode script that runs in your content script and dumps all the selectors it's using, plus whether they currently resolve. We have a hotkey (Ctrl+Shift+Alt+P) that shows a debug overlay with all probe results. When a CRM update breaks the extension, this is the fastest way to find the broken selector.

Injecting UI

You're rendering inside someone else's CSS. Their styles will leak into yours, and yours into theirs, if you're not careful.

Two approaches work:

  1. Shadow DOM. Mount your UI inside a shadow root. CSS doesn't cross the boundary in either direction. This is what we did. Trade-off: some host-page utilities (like their tooltip library) don't work inside shadow DOMs.

  2. Heavily scoped CSS classes. Prefix every class with something unique (gift-ext-button, gift-ext-modal). Use it consistently. Reset every style you care about explicitly.

Shadow DOM is cleaner. Reach for it first.

function mountUI(host: HTMLElement) {
  const shadow = host.attachShadow({ mode: 'open' })
  const root = document.createElement('div')
  shadow.appendChild(root)
  
  // Inject stylesheet inside the shadow root
  const style = document.createElement('style')
  style.textContent = SHADOW_STYLES
  shadow.appendChild(style)
  
  // Render your UI into `root` — many frameworks (Preact, Svelte) support shadow-DOM mounting.
  renderApp(root)
}

Auth that survives a service-worker sleep

The background service worker sleeps when idle. Module-level variables are wiped. You have to persist auth state in chrome.storage:

// auth.ts
import { storage } from './storage'

const KEY = 'auth:state'

type AuthState = { accessToken: string; refreshToken: string; expiresAt: number }

export async function getAuthState(): Promise<AuthState | null> {
  return (await storage.local.get(KEY))[KEY] ?? null
}

export async function setAuthState(state: AuthState): Promise<void> {
  await storage.local.set({ [KEY]: state })
}

export async function clearAuthState(): Promise<void> {
  await storage.local.remove(KEY)
}

chrome.storage.local is async, but it's fast (a few ms). Use it as your source of truth for auth. The trade-off: it's persistent across browser restarts and not encrypted (it's in the user's profile directory). Don't store any secret you wouldn't be comfortable with the user finding.

For our extension, we stored a refresh token in chrome.storage and minted short-lived access tokens on demand. If a malicious extension or piece of malware grabbed the storage, they'd have a refresh token they could exchange for access tokens — same threat shape as a leaked browser cookie. Acceptable for our threat model.

Handling the host's auth changes

A subtle bug: the user's HubSpot session has its own auth state, independent of our extension's. The HubSpot tab might be logged out (session expired) while our extension still thinks the user is "on a HubSpot page." Our calls to look up host context fail.

The fix: detect host auth state via the host's own indicators. If the page redirects to a HubSpot login URL, recognize that and pause the extension's UI in that tab. We had a small detectHostAuthState() function per host:

function detectHubSpotAuthState(): 'authenticated' | 'unauthenticated' | 'unknown' {
  if (location.hostname.includes('login')) return 'unauthenticated'
  if (document.querySelector('[data-test-id="logged-in-user"]')) return 'authenticated'
  return 'unknown'
}

When unauthenticated, the extension shows a "please log in to HubSpot" message instead of its normal UI.

Cross-origin and CSP

The host page has its own CSP. You're not allowed to inject script tags from arbitrary origins. Manifest v3 enforces this even harder.

What works: anything you ship in your extension bundle, served from chrome-extension://... URLs, is allowed in content scripts regardless of the host's CSP.

What doesn't work: <script src="https://your-api.example.com/widget.js"> injected into the host page. Even if your API serves it, the host's CSP will block.

The pattern: ship everything in the extension bundle. The content script does the rendering; the host doesn't need to know about your code.

Testing

Three layers of test.

Unit tests for the background worker logic. Standard Vitest setup. Mock chrome.runtime with a small stub.

Integration tests for the content script + DOM interaction. Use Playwright. Spin up a static HTML fixture that mimics the host page, load it, inject the content script, assert on DOM changes.

Manual smoke tests in the actual host. Once a week, walk through the happy path in HubSpot and SalesLoft. The hosts change their DOM often enough that automated tests don't catch everything. Budget the time.

Distribution

Chrome Web Store has a review process. Allow 7-10 business days for the first submission and 1-3 days for updates. Permissions in your manifest get scrutinized — every permission has to be justified in the review notes. We learned to ask for the minimum and request more in a follow-up update if needed.

Also: the store will reject extensions that load remote code, even Manifest v3 ones if you're sneaky about it. Don't be sneaky.

The takeaway

A Chrome extension is a small piece of code that lives in someone else's app. The hard parts aren't the JS — they're the integration: finding selectors that survive the host's updates, isolating your CSS from theirs, handling auth state across two systems, and dealing with Manifest v3's restrictions.

The architecture is straightforward: content scripts for host integration, background service worker for cross-host logic, persistent storage for auth state, message passing for everything. Each piece is small. The aggregate is a useful tool that saves users real time, every day.