AverageDevs
AI

Build a Chrome Extension that Uses GPT to Summarize Web Pages in Real Time

Step by step guide to building a production ready Chrome extension with GPT powered page summaries, including architecture, TypeScript code, and performance tips.

Build a Chrome Extension that Uses GPT to Summarize Web Pages in Real Time

Most developers first meet Chrome extensions as a small utility script that scratches a personal itch. Then someone asks for AI powered summaries, the scope quietly grows, and suddenly you are thinking about content scripts, background workers, rate limits, and what happens when a user opens twenty tabs at once. Building a Chrome extension that uses GPT to summarize pages in real time is not hard, but it does require a bit of structure if you want it to feel reliable instead of experimental.

In this guide we will build a modern Chrome extension that highlights the current page, sends relevant content to GPT, and shows a concise summary in a popup. We will focus on Manifest V3, TypeScript, and patterns that scale beyond a weekend toy. Along the way you will see how this ties into broader themes from Integrate OpenAI API in Next.js and AI‑Summarized Dashboards: From Walls of Charts to Actionable Narratives, where AI is used as a careful assistant instead of a mysterious black box.

What we are building

The extension will do three things well:

  1. Capture the main text content of the current web page using a content script.
  2. Send that content to a background service worker which calls a GPT style API.
  3. Display a clean, readable summary in a popup, updating in near real time as the page or selection changes.

There are hundreds of variations on this idea. We will aim for a version that is simple enough to follow, but architected well enough that you can extend it to features like:

  • Different summary styles, such as bullets, action items, or beginner friendly explanations.
  • Language selection for summaries.
  • Saving summaries to your own backend and surfacing them in other tools, using patterns similar to Document Q&A with Next.js and LangChain.

We will assume you are comfortable with TypeScript and React. If you want to go deeper on React performance and bundle hygiene, pair this guide with React Performance and Bundle Size Optimization in 2025.

High level architecture

Chrome extensions have a particular shape. For our summarizer we will use three main pieces.

          ┌───────────────────────────────────────────┐
          │              Chrome Browser               │
          └───────────────────────────────────────────┘
                 │                         │
                 │                         │
          ┌──────▼───────┐          ┌──────▼───────┐
          │ Content Script│          │   Popup UI  │
          │  (page side) │          │  (React)    │
          └──────┬───────┘          └──────┬──────┘
                 │ messages                │ messages
          ┌──────▼─────────────────────────▼──────┐
          │         Background Service Worker      │
          │  - Receives text snippets             │
          │  - Calls GPT API                      │
          │  - Caches results                     │
          └──────┬───────────────────────────────┘
                 │ HTTP
                 v
          ┌───────────────────┐
          │   GPT Provider    │
          └───────────────────┘
  • The content script runs in the context of each web page and can read the DOM.
  • The background service worker acts as the brain that calls GPT and manages rate limiting.
  • The popup is a small React UI that shows the summary to the user.

The moving parts talk through Chrome messaging APIs. Once you are comfortable with this pattern, you will recognize similar boundaries in other architectures, such as the ports and adapters described in Practical Guide to Implementing Clean Architecture in Full‑Stack Projects.

Project setup and manifest

You can structure the extension as a small TypeScript project that compiles to a dist/ folder.

chrome-gpt-summarizer/
  src/
    manifest.json
    background.ts
    content-script.ts
    popup/
      index.html
      popup.tsx
  tsconfig.json
  package.json
  vite.config.ts  (or your bundler of choice)

We will not go deep into bundler configuration here, but the high level idea is simple: build TypeScript entry points into plain JavaScript files referenced from manifest.json. You can adapt this to tools like Vite or Webpack that you might already use on projects similar to Vibe Coding with Cursor.

Manifest V3 configuration

Here is a minimal Manifest V3 file adapted for our extension:

{
  "manifest_version": 3,
  "name": "GPT Page Summarizer",
  "description": "Summarize the current page in real time using GPT.",
  "version": "1.0.0",
  "permissions": ["scripting", "activeTab", "storage"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "action": {
    "default_popup": "popup/index.html",
    "default_title": "Summarize this page"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content-script.js"],
      "run_at": "document_idle"
    }
  ]
}

In a production extension you would likely scope matches more tightly and ask for fewer host permissions, but for development this is a practical starting point.

Designing message contracts

Before we write any code, we should define the messages that flow between content script, background, and popup. This keeps TypeScript happy and your future self sane.

// src/types/messages.ts

export type ExtractTextRequest = {
  type: "extract-text-request";
};

export type ExtractTextResponse = {
  type: "extract-text-response";
  payload: {
    text: string;
  };
};

export type SummarizeRequest = {
  type: "summarize-request";
  payload: {
    text: string;
    maxTokens: number;
    style: "default" | "bullets" | "beginner";
  };
};

export type SummarizeResponse = {
  type: "summarize-response";
  payload: {
    summary: string;
  };
};

export type Message =
  | ExtractTextRequest
  | ExtractTextResponse
  | SummarizeRequest
  | SummarizeResponse;

You can share this small contract file across all three parts of the extension. The structure might remind you of Application layer DTOs in clean architecture, where IO details live at the edge and core logic works on well typed data.

Implementing the content script

The content script runs in every tab that matches your manifest rules. Its job is simple:

  • When asked, extract the relevant text from the page.
  • Send that text back to the background service worker.

We will keep the extraction logic simple for now, but you can later improve it with heuristics or libraries that remove navigation, footers, and comments.

// src/content-script.ts
import type { ExtractTextRequest, ExtractTextResponse, Message } from "./types/messages";

const extractVisibleText = (): string => {
  const articleElements = document.querySelectorAll("article, main");
  const target = articleElements.length > 0 ? articleElements[0] : document.body;

  const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, null);
  const parts: string[] = [];

  let node = walker.nextNode();
  while (node) {
    const value = node.textContent ?? "";
    const trimmed = value.replace(/\s+/g, " ").trim();
    if (trimmed.length > 2) {
      parts.push(trimmed);
    }
    node = walker.nextNode();
  }

  return parts.join(" ");
};

chrome.runtime.onMessage.addListener(
  (message: Message, _sender, sendResponse: (response: Message) => void): boolean => {
    if (message.type === "extract-text-request") {
      const text = extractVisibleText();
      const response: ExtractTextResponse = {
        type: "extract-text-response",
        payload: { text },
      };
      sendResponse(response);
      return true;
    }
    return false;
  }
);

If you want to support highlighting specific selections, you can extend this script to check window.getSelection() first and fall back to document wide extraction only when no selection exists.

Implementing the background worker with GPT

The background service worker is the only part of the extension that talks to external APIs. Keeping this responsibility isolated makes it easier to reason about rate limits, error handling, and credentials.

Configuring API access

You should never hard code API keys in source control. For development you can:

  • Use a .env file that your bundler inlines into the background script at build time.
  • Store the key in Chrome sync storage and load it at startup.

For illustration we will assume an environment variable GPT_API_KEY that your bundler replaces with the actual value.

// src/background.ts
import type {
  Message,
  SummarizeRequest,
  SummarizeResponse,
  ExtractTextRequest,
  ExtractTextResponse,
} from "./types/messages";

type SummarizeParams = {
  text: string;
  maxTokens: number;
  style: "default" | "bullets" | "beginner";
};

const summarizeWithGpt = async (params: SummarizeParams): Promise<string> => {
  const apiKey = process.env.GPT_API_KEY;
  if (!apiKey) {
    throw new Error("Missing GPT_API_KEY");
  }

  const systemPrompt = "You are a helpful assistant that summarizes web pages for developers.";

  const styleInstruction =
    params.style === "bullets"
      ? "Return a concise bullet list of key points."
      : params.style === "beginner"
      ? "Explain the content in simple language for a beginner developer."
      : "Return a short, neutral summary that captures the main ideas.";

  const userPrompt = `Summarize the following web page content.\n\n${styleInstruction}\n\nContent:\n${params.text.slice(
    0,
    6000
  )}`;

  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: "gpt-4.1-mini",
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt },
      ],
      max_tokens: params.maxTokens,
      temperature: 0.2,
    }),
  });

  if (!response.ok) {
    throw new Error(`GPT API error status=${response.status}`);
  }

  const data = await response.json();
  const content: string =
    data.choices?.[0]?.message?.content ?? "No summary generated due to an unexpected response shape.";
  return content.trim();
};

chrome.runtime.onMessage.addListener(
  (message: Message, sender, sendResponse: (response: Message) => void): boolean => {
    if (message.type === "summarize-request") {
      const request = message as SummarizeRequest;

      summarizeWithGpt(request.payload)
        .then((summary) => {
          const response: SummarizeResponse = {
            type: "summarize-response",
            payload: { summary },
          };
          sendResponse(response);
        })
        .catch((error) => {
          console.error("Failed to summarize", { error, sender });
          const response: SummarizeResponse = {
            type: "summarize-response",
            payload: {
              summary: "Could not generate summary. Please check your API key and try again.",
            },
          };
          sendResponse(response);
        });

      return true;
    }

    if (message.type === "extract-text-request") {
      // Forward request to the active tab content script
      if (sender.tab?.id != null) {
        chrome.tabs.sendMessage<ExtractTextRequest, ExtractTextResponse>(
          sender.tab.id,
          message,
          (response) => {
            if (response) {
              sendResponse(response);
            }
          }
        );
        return true;
      }
    }

    return false;
  }
);

In a more advanced version you would:

Building the popup UI with React and TypeScript

The popup is a small HTML page that Chrome opens when the user clicks the extension icon. You can build it with plain JavaScript, but React and TypeScript give you a more pleasant developer experience, especially if you want to add options or richer controls later.

<!-- src/popup/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>GPT Page Summarizer</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="popup.js" type="module"></script>
  </body>
</html>

Your bundler will compile popup.tsx to popup.js and place both in the dist/popup folder that the manifest references.

React popup component

// src/popup/popup.tsx
import React, { useEffect, useState, useCallback } from "react";
import { createRoot } from "react-dom/client";
import type {
  ExtractTextRequest,
  ExtractTextResponse,
  SummarizeRequest,
  SummarizeResponse,
} from "../types/messages";

const DEFAULT_MAX_TOKENS = 256;

const App = (): JSX.Element => {
  const [summary, setSummary] = useState<string>("");
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const [style, setStyle] = useState<"default" | "bullets" | "beginner">("default");

  const requestSummary = useCallback(async () => {
    setLoading(true);
    setError(null);
    setSummary("");

    try {
      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
      if (!tab?.id) {
        throw new Error("No active tab found.");
      }

      const extractRequest: ExtractTextRequest = { type: "extract-text-request" };

      const extractResponse = await chrome.tabs.sendMessage<ExtractTextRequest, ExtractTextResponse>(
        tab.id,
        extractRequest
      );

      const text = extractResponse?.payload.text ?? "";
      if (!text || text.length < 30) {
        setError("Could not extract meaningful text from this page.");
        setLoading(false);
        return;
      }

      const summarizeRequest: SummarizeRequest = {
        type: "summarize-request",
        payload: {
          text,
          maxTokens: DEFAULT_MAX_TOKENS,
          style,
        },
      };

      const summarizeResponse = await chrome.runtime.sendMessage<SummarizeRequest, SummarizeResponse>(
        summarizeRequest
      );

      setSummary(summarizeResponse.payload.summary);
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : "Unknown error occurred.";
      setError(message);
    } finally {
      setLoading(false);
    }
  }, [style]);

  useEffect(() => {
    void requestSummary();
  }, [requestSummary]);

  return (
    <div style={{ width: 380, padding: 12, fontFamily: "system-ui, -apple-system, BlinkMacSystemFont" }}>
      <h1 style={{ fontSize: 16, margin: "0 0 8px" }}>GPT Page Summarizer</h1>
      <p style={{ fontSize: 12, margin: "0 0 8px", color: "#555" }}>
        Quickly summarize the current page content using GPT. Choose a style, then refresh if needed.
      </p>

      <div style={{ marginBottom: 8 }}>
        <label style={{ fontSize: 12, marginRight: 4 }}>Style:</label>
        <select
          value={style}
          onChange={(e) => setStyle(e.target.value as typeof style)}
          style={{ fontSize: 12 }}
        >
          <option value="default">Neutral summary</option>
          <option value="bullets">Key bullets</option>
          <option value="beginner">Beginner friendly</option>
        </select>
        <button
          type="button"
          onClick={() => requestSummary()}
          disabled={loading}
          style={{ marginLeft: 8, fontSize: 12 }}
        >
          {loading ? "Summarizing..." : "Refresh"}
        </button>
      </div>

      {error && (
        <div style={{ color: "#b00020", fontSize: 12, marginBottom: 8 }}>
          {error}
        </div>
      )}

      <div
        style={{
          maxHeight: 320,
          overflowY: "auto",
          fontSize: 13,
          lineHeight: 1.4,
          border: "1px solid #ddd",
          borderRadius: 4,
          padding: 8,
          backgroundColor: "#fafafa",
          whiteSpace: "pre-wrap",
        }}
      >
        {summary || (loading ? "Generating summary..." : "No summary yet.")}
      </div>
    </div>
  );
};

const container = document.getElementById("root");
if (container) {
  const root = createRoot(container);
  root.render(<App />);
}

This popup follows a small, predictable pattern:

  • Utility constants and types are at the top.
  • React state and effects handle the user flow.
  • All Chrome interactions are wrapped in a single requestSummary callback.

The style choices are intentionally minimal but you can layer in design systems or animation libraries similar to those discussed in React Animation Libraries Compared once the core logic is stable.

Handling real time behavior

So far we generate a summary on demand when the popup opens. Real time is a slippery term here. For most users, "real time" means "reacts quickly to what I am doing without me constantly thinking about it."

You can approach this in two progressive steps:

  1. Refresh the summary whenever the user changes the style or clicks a refresh button.
  2. Optionally listen for significant events in the content script, such as navigation in single page apps, and notify the background worker to update caches.

A light touch that still feels responsive is to:

  • Cache summaries by { url, style } in chrome.storage.
  • On popup open, check the cache first.
  • If the current page scroll position or DOM has changed significantly since last summary, show a subtle "Page changed, click refresh for an updated summary" hint.

This is conceptually similar to how you might design auto refreshed yet controlled summaries in a product analytics setting, as described in AI‑Summarized Dashboards: From Walls of Charts to Actionable Narratives.

Error handling, limits, and user trust

Calling GPT from a browser extension introduces a few practical concerns:

  • What happens when the API key is misconfigured
  • How do you handle network errors or rate limits
  • How much page content is safe and sensible to send

You already saw a basic error handling pattern in the background worker. Here are a few additional recommendations:

  • Truncate page content before sending it to GPT and surface that choice to the user if necessary.
  • Use descriptive, non alarming error messages that tell the user what they can do next.
  • Avoid automatic retries on aggressive schedules; the extension runs on the user machine, so tight retry loops feel like a hot laptop.

If you later connect your extension to a backend, you can adopt more advanced patterns like correlation IDs and sagas from Error Handling Patterns in Distributed Systems so that failures stay visible and recoverable instead of mysterious.

Packaging, testing, and distribution

Once the extension builds cleanly you can:

  1. Run your bundler in production mode to generate a dist/ folder.
  2. Use Chrome's "Load unpacked" feature in developer mode to point to dist/.
  3. Iterate on content extraction and summarization prompts until the experience feels natural.

For automated testing:

  • Unit test the pure functions such as extractVisibleText and small GPT prompt builders.
  • Smoke test the popup with tools like Playwright or Puppeteer, which is the same family of tooling you might already use for e2e flows in your web apps.
  • If you store summaries server side, reuse quality practices from How AI Helps Maintain Code Quality and Reduce Bugs so that AI code paths get the same care as everything else.

When you are ready for distribution:

  • Follow Chrome Web Store guidelines for API usage and privacy.
  • Clearly explain what data is sent to GPT and when.
  • Consider offering an "offline only" mode where the extension simply highlights important sections locally without calling APIs, similar in spirit to the offline friendly design patterns in Offline Ready PWAs.

Conclusion and next steps

Building a Chrome extension that uses GPT to summarize web pages in real time is less about clever prompts and more about thoughtful plumbing. The content script, background worker, and popup each have a focused responsibility, and TypeScript helps keep the seams between them honest. Once that foundation is in place, you can iterate quickly on prompts, UI, and behavior without fighting the platform.

If you want to take this further, here are a few directions:

The key is to treat your extension like any other product feature: clear responsibilities, predictable error handling, and a careful user story. GPT is the engine, but the extension you design around it is what turns that engine into a tool developers actually use every day.