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 (React Component)
// 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.