Skip to main content

Build a Chrome Extension from a SaaS Boilerplate 2026

·StarterPick Team
chrome-extensionmanifest-v3saas-boilerplatenextjs2026

TL;DR

Building a Chrome extension for your SaaS in 2026 means Manifest V3 — service workers replace background pages, Promises replace callbacks. The pattern: React popup UI + content script + service worker (background) + shared API calls to your existing SaaS backend. Use plasmo (the Chrome extension framework for React) to avoid boilerplate setup.

Key Takeaways

  • Manifest V3: Service workers only (no persistent background pages), declarativeNetRequest, stricter CSP
  • Plasmo: React + TypeScript Chrome extension framework, handles MV3 complexity
  • Auth: Store session token in chrome.storage.local, send to your Next.js API
  • Content scripts: Inject UI into any page, communicate via chrome.runtime.sendMessage
  • Testing: chrome://extensions/ → "Load unpacked", or use Playwright Extension API

Project Structure with Plasmo

npm create plasmo my-saas-extension
cd my-saas-extension && npm install
my-saas-extension/
├── popup.tsx          → Extension popup UI
├── contents/          → Content scripts (run on web pages)
│   └── injector.tsx
├── background.ts      → Service worker
├── options.tsx        → Options page
├── package.json
└── .plasmo/           → Auto-generated manifest + build

// popup.tsx — React popup UI:
import { useState, useEffect } from 'react';
import { useStorage } from '@plasmohq/storage/hook';

function IndexPopup() {
  const [token] = useStorage<string>('authToken');
  const [user, setUser] = useState<any>(null);
  const [loading, setLoading] = useState(false);

  // Fetch user from your SaaS backend:
  useEffect(() => {
    if (!token) return;
    fetch('https://yourapp.com/api/user', {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then(r => r.json())
      .then(setUser);
  }, [token]);

  if (!token) {
    return (
      <div className="p-4 w-72">
        <p className="mb-3">Sign in to use the extension</p>
        <a
          href="https://yourapp.com/login?source=extension"
          target="_blank"
          className="bg-blue-500 text-white px-4 py-2 rounded block text-center"
        >
          Sign In
        </a>
      </div>
    );
  }

  return (
    <div className="p-4 w-72">
      <p className="font-semibold">Welcome, {user?.name}!</p>
      <p className="text-sm text-gray-500">{user?.plan} plan</p>
      <hr className="my-3" />
      <button
        onClick={() => captureCurrentPage()}
        className="w-full bg-blue-500 text-white py-2 rounded"
      >
        Capture This Page
      </button>
    </div>
  );
}

async function captureCurrentPage() {
  // Send message to content script:
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const response = await chrome.tabs.sendMessage(tab.id!, { action: 'capture' });
  console.log('Captured:', response);
}

export default IndexPopup;

Content Script (Inject into Pages)

// contents/injector.tsx — runs on every page:
import type { PlasmoCSConfig } from 'plasmo';

// Which pages to run on:
export const config: PlasmoCSConfig = {
  matches: ['https://*/*', 'http://*/*'],
  run_at: 'document_idle',
};

// Listen for messages from popup:
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'capture') {
    const data = {
      url: window.location.href,
      title: document.title,
      selection: window.getSelection()?.toString() ?? '',
      meta: {
        description: document.querySelector('meta[name="description"]')?.getAttribute('content'),
        ogImage: document.querySelector('meta[property="og:image"]')?.getAttribute('content'),
      },
    };

    // Send to background service worker (which calls your API):
    chrome.runtime.sendMessage({ action: 'save-capture', data }, (response) => {
      sendResponse(response);
    });
  }
  return true;  // Keep channel open for async response
});

Background Service Worker

// background.ts — service worker for API calls:
import { Storage } from '@plasmohq/storage';

const storage = new Storage();

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'save-capture') {
    handleCapture(request.data).then(sendResponse);
    return true;
  }
});

async function handleCapture(data: any) {
  const token = await storage.get('authToken');
  if (!token) return { error: 'Not authenticated' };

  try {
    const res = await fetch('https://yourapp.com/api/captures', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify(data),
    });

    if (!res.ok) return { error: 'Failed to save' };
    return { success: true, id: (await res.json()).id };
  } catch (error) {
    return { error: 'Network error' };
  }
}

Auth: Connect Extension to Your SaaS

// In your Next.js app — generate extension auth token:
// app/api/extension/auth/route.ts
export async function POST() {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });

  // Create a long-lived extension token:
  const extensionToken = crypto.randomUUID();
  await db.extensionToken.create({
    data: {
      userId: session.user.id,
      token: extensionToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    },
  });

  return Response.json({ token: extensionToken });
}
// After login on yourapp.com, set the token in extension:
// This runs in your web app's client-side code:
async function connectExtension() {
  const { token } = await fetch('/api/extension/auth', { method: 'POST' }).then(r => r.json());

  // Send token to extension via postMessage:
  window.postMessage({ type: 'YOURAPP_EXTENSION_TOKEN', token }, '*');
}

// In content script — receive token from web app:
window.addEventListener('message', async (event) => {
  if (event.data?.type === 'YOURAPP_EXTENSION_TOKEN') {
    const storage = new Storage();
    await storage.set('authToken', event.data.token);
    // Now extension is authenticated!
  }
});

Build and Publish

# Development:
npm run dev  # Loads extension at build/chrome-mv3-dev/

# Production build:
npm run build

# Package for Chrome Web Store:
npm run package
# Output: build/chrome-mv3-prod.zip → upload to developer.chrome.com
// package.json plasmo config for SaaS extension:
{
  "name": "my-saas-extension",
  "version": "1.0.0",
  "displayName": "My SaaS Extension",
  "description": "The official browser extension for My SaaS",
  "homepage": "https://yourapp.com",
  "icons": {
    "16": "assets/icon16.png",
    "48": "assets/icon48.png",
    "128": "assets/icon128.png"
  },
  "permissions": ["storage", "activeTab", "tabs"]
}

Decision Guide

Use Plasmo if:
  → Building React-based extension
  → Want modern DX (HMR, TypeScript, CSP handled)
  → New extension from scratch

Use CRXJS + Vite if:
  → Need Vite ecosystem (Tailwind v4, ShadCN)
  → More control over build process

Extension auth pattern:
  → Generate dedicated extension tokens (not session cookies)
  → 30-day expiry, revokable from dashboard
  → NEVER use short-lived session cookies in extensions

Find SaaS boilerplates at StarterPick.

Comments