DEV Community

Cover image for Supercharge Your Blog with Chrome's Built-in AI Summarizer API
Vitor Ferreira
Vitor Ferreira

Posted on

Supercharge Your Blog with Chrome's Built-in AI Summarizer API

Web applications are entering a new era: On-Device AI.

Instead of routing every prompt to costly cloud endpoints, managing rate limits, and paying monthly API subscriptions, modern browsers (Chrome 138+) now ship with Gemini Nano directly built-in.

One of the most exciting additions is the Chrome Summarizer API. It allows you to distill lengthy articles, documentation, or chat logs into concise summaries, key points, or headlinesโ€”completely client-side, with zero network latency, 100% privacy, and no API keys required.

In this article, weโ€™ll build a production-ready, streaming AI Summarizer component in React and TypeScript, complete with model download progress tracking, multiple summary formats, and official typings via @types/dom-chromium-ai.


๐Ÿš€ Why On-Device Summarization?

  1. ๐Ÿ”’ Zero Data Leakage: Content never leaves the user's machine.
  2. โšก Ultra-Low Latency: Local execution means near-instant time-to-first-token.
  3. ๐Ÿ’ธ Zero Cloud Costs: No OpenAI / Anthropic bills for reading or summarizing pages.
  4. ๐Ÿ“ถ Offline Capable: Works without an active internet connection once the model weights are downloaded.

๐Ÿ“ฆ 1. Setting Up TypeScript Definitions

Chrome's Built-in AI APIs adhere to the emerging W3C Web Incubator / WebML specs. To get autocomplete and compile-time validation, install the official typings:

npm install -D @types/dom-chromium-ai
Enter fullscreen mode Exit fullscreen mode

Update your tsconfig.app.json (or tsconfig.json) to register the types:

{
  "compilerOptions": {
    "types": ["vite/client", "dom-chromium-ai"]
  }
}
Enter fullscreen mode Exit fullscreen mode

This gives you first-class types for Summarizer, LanguageModel, Writer, Rewriter, Translator, and more!


๐Ÿ” 2. Probing API Availability

Before triggering AI inference, we must check if the user's browser supports the API and whether the local Gemini Nano model is ready:

// src/lib/summarizer.ts
/// <reference types="dom-chromium-ai" />

export type SummarizerAvailabilityStatus =
  | 'readily'
  | 'after-download'
  | 'available'
  | 'downloadable'
  | 'downloading'
  | 'unavailable'
  | 'no'
  | 'unsupported';

export function getSummarizerAPI() {
  if (typeof window === 'undefined') return null;

  const anyWin = window as any;
  const anySelf = typeof self !== 'undefined' ? (self as any) : null;

  return (
    anyWin.ai?.summarizer ||
    anySelf?.ai?.summarizer ||
    anyWin.Summarizer ||
    anySelf?.Summarizer ||
    null
  );
}

export async function checkSummarizerAvailability(
  options?: SummarizerCreateOptions
): Promise<SummarizerAvailabilityStatus> {
  try {
    const api = getSummarizerAPI();
    if (!api) return 'unsupported';

    if (typeof api.availability === 'function') {
      return (await api.availability(options)) as SummarizerAvailabilityStatus;
    }

    if (typeof api.capabilities === 'function') {
      const caps = await api.capabilities();
      return (caps?.available as SummarizerAvailabilityStatus) || 'unsupported';
    }

    return 'unsupported';
  } catch (err) {
    console.debug('Availability probe failed:', err);
    return 'unsupported';
  }
}

export function isSummarizerUsable(status: SummarizerAvailabilityStatus): boolean {
  return ['readily', 'available', 'after-download', 'downloadable', 'downloading'].includes(status);
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Pro-Tip: If the browser returns unsupported or no, you can simply hide the UI or render a fallback so non-Chrome users never experience broken buttons.


โšก 3. Handling Model Downloads & Real-Time Streaming

The Summarizer API allows you to pass a monitor callback to observe downloading progress when the model is downloaded for the first time.

Additionally, summarizeStreaming() returns a ReadableStream (or AsyncIterable), letting us stream tokens sequentially into our UI:

// src/lib/summarizer.ts
export interface SummarizeExecutionOptions {
  content: string;
  type: 'key-points' | 'tldr' | 'teaser' | 'headline';
  format: 'markdown' | 'plain-text';
  length: 'short' | 'medium' | 'long';
  sharedContext?: string;
  onChunk?: (fullText: string, latestDelta: string) => void;
  onDownloadProgress?: (percent: number) => void;
  signal?: AbortSignal;
}

export async function generateArticleSummary({
  content,
  type,
  format,
  length,
  sharedContext,
  onChunk,
  onDownloadProgress,
  signal,
}: SummarizeExecutionOptions): Promise<string> {
  const api = getSummarizerAPI();
  if (!api) throw new Error('Summarizer API not supported.');

  const createOptions: SummarizerCreateOptions = {
    type,
    format,
    length,
    outputLanguage: 'en',
    sharedContext: sharedContext || 'Technical software article',
    signal,
    monitor: (monitor: CreateMonitor) => {
      monitor.addEventListener('downloadprogress', (e: ProgressEvent) => {
        if (e.total && e.total > 0) {
          onDownloadProgress?.(Math.min(100, Math.round((e.loaded / e.total) * 100)));
        } else if (e.loaded > 0) {
          onDownloadProgress?.(Math.min(99, Math.round(e.loaded * 100)));
        }
      });
    },
  };

  const summarizer = await api.create(createOptions);

  try {
    if (typeof summarizer.summarizeStreaming === 'function') {
      const stream = summarizer.summarizeStreaming(content, { signal });
      let accumulated = '';

      // Async iterable reader
      if (stream && typeof (stream as any)[Symbol.asyncIterator] === 'function') {
        for await (const chunk of stream as AsyncIterable<string>) {
          if (signal?.aborted) break;
          accumulated += chunk;
          onChunk?.(accumulated, chunk);
        }
        return accumulated;
      }

      // ReadableStream reader fallback
      if (stream && typeof (stream as ReadableStream<string>).getReader === 'function') {
        const reader = (stream as ReadableStream<string>).getReader();
        try {
          while (true) {
            if (signal?.aborted) break;
            const { done, value } = await reader.read();
            if (done) break;
            if (value) {
              accumulated += value;
              onChunk?.(accumulated, value);
            }
          }
        } finally {
          reader.releaseLock();
        }
        return accumulated;
      }
    }

    // Direct fallback
    const summary = await summarizer.summarize(content, { signal });
    onChunk?.(summary, summary);
    return summary;
  } finally {
    summarizer.destroy();
  }
}
Enter fullscreen mode Exit fullscreen mode

๐ŸŽจ 4. Building the React HUD Summarizer Component

Now, let's create a sleek React component that:

  • Hides automatically if the browser lacks support.
  • Lets readers choose summary styles: Key Points, TL;DR, Teaser, or Headline.
  • Renders streaming text in real time with an animated cursor.
  • Provides a one-click copy button.
// src/components/ArticleSummarizer.tsx
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, Zap, Copy, Check, RotateCw, Square } from 'lucide-react';
import {
  checkSummarizerAvailability,
  isSummarizerUsable,
  generateArticleSummary,
  type SummarizerAvailabilityStatus,
} from '../lib/summarizer';

interface ArticleSummarizerProps {
  content: string;
  articleTitle?: string;
}

export const ArticleSummarizer: React.FC<ArticleSummarizerProps> = ({
  content,
  articleTitle,
}) => {
  const [availability, setAvailability] = useState<SummarizerAvailabilityStatus | null>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [type, setType] = useState<'key-points' | 'tldr' | 'teaser' | 'headline'>('key-points');
  const [length, setLength] = useState<'short' | 'medium' | 'long'>('medium');
  const [summary, setSummary] = useState('');
  const [isGenerating, setIsGenerating] = useState(false);
  const [progress, setProgress] = useState<number | null>(null);
  const [copied, setCopied] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    let isMounted = true;
    void checkSummarizerAvailability().then((status) => {
      if (isMounted) setAvailability(status);
    });
    return () => { isMounted = false; };
  }, []);

  // Gracefully hide if browser has no Gemini Nano support
  if (!availability || !isSummarizerUsable(availability)) {
    return null;
  }

  const handleSummarize = async () => {
    if (isGenerating) return;
    setSummary('');
    setProgress(null);
    setIsGenerating(true);
    setIsOpen(true);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      await generateArticleSummary({
        content,
        type,
        format: 'markdown',
        length,
        sharedContext: articleTitle ? `Article titled "${articleTitle}"` : undefined,
        signal: controller.signal,
        onDownloadProgress: (pct) => setProgress(pct),
        onChunk: (accumulated) => setSummary(accumulated),
      });
    } catch (err: any) {
      if (!controller.signal.aborted) {
        console.error('Summarization failed:', err);
      }
    } finally {
      setIsGenerating(false);
      setProgress(null);
    }
  };

  const handleCopy = () => {
    void navigator.clipboard.writeText(summary);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="rounded-xl border border-lime-500/30 bg-[#0a0d09]/95 p-4 text-slate-200">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <Sparkles className="text-lime-400 animate-pulse" size={18} />
          <span className="font-mono text-xs font-bold text-lime-400 uppercase">
            GEMINI NANO // ON-DEVICE AI
          </span>
        </div>

        {!summary && !isGenerating ? (
          <button
            onClick={() => void handleSummarize()}
            className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-mono font-bold hover:bg-lime-300 transition-all cursor-pointer"
          >
            <Zap size={13} />
            <span>SUMMARIZE ARTICLE</span>
          </button>
        ) : isGenerating ? (
          <button
            onClick={() => abortRef.current?.abort()}
            className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-rose-500/20 text-rose-300 border border-rose-500/40 text-xs font-mono"
          >
            <Square size={12} className="fill-rose-400" />
            <span>HALT</span>
          </button>
        ) : null}
      </div>

      {isOpen && (
        <div className="mt-4 space-y-4">
          {/* Mode Selector */}
          <div className="flex flex-wrap gap-2 text-xs font-mono">
            {(['key-points', 'tldr', 'teaser', 'headline'] as const).map((t) => (
              <button
                key={t}
                onClick={() => setType(t)}
                disabled={isGenerating}
                className={`px-2.5 py-1 rounded border transition-colors cursor-pointer ${
                  type === t
                    ? 'border-lime-400 bg-lime-400/20 text-lime-300'
                    : 'border-[#232f1e] bg-black/40 text-slate-400'
                }`}
              >
                {t.toUpperCase()}
              </button>
            ))}
          </div>

          {/* Download Progress */}
          {progress !== null && (
            <div className="space-y-1 font-mono text-xs text-lime-300">
              <div className="flex justify-between">
                <span>Downloading Gemini Nano Weights...</span>
                <span>{progress}%</span>
              </div>
              <div className="h-1.5 w-full bg-black rounded-full overflow-hidden border border-lime-500/30">
                <div className="h-full bg-lime-400 transition-all" style={{ width: `${progress}%` }} />
              </div>
            </div>
          )}

          {/* Stream Output */}
          {summary && (
            <div className="p-4 rounded-lg bg-black/60 border border-[#232f1e] whitespace-pre-wrap text-sm leading-relaxed">
              {summary}
              {isGenerating && <span className="inline-block w-2 h-4 ml-1 bg-lime-400 animate-pulse" />}
            </div>
          )}

          {/* Action Footer */}
          {summary && !isGenerating && (
            <div className="flex justify-between items-center text-xs font-mono">
              <button
                onClick={() => void handleSummarize()}
                className="flex items-center gap-1.5 text-slate-400 hover:text-lime-400 transition-colors"
              >
                <RotateCw size={12} />
                <span>REGENERATE</span>
              </button>

              <button
                onClick={handleCopy}
                className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-[#232f1e] bg-black/40 text-slate-300 hover:text-lime-400"
              >
                {copied ? <Check size={13} className="text-lime-400" /> : <Copy size={13} />}
                <span>{copied ? 'COPIED' : 'COPY'}</span>
              </button>
            </div>
          )}
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

๐Ÿ› ๏ธ 5. Testing it in Chrome

To test the Summarizer API in your browser:

  1. Use Google Chrome (Canary / Dev / Beta 138+).
  2. Open chrome://flags in your address bar:
    • #optimization-guide-on-device-model: Set to Enabled BypassPrefRequirement.
    • #summarization-api-for-gemini-nano: Set to Enabled.
  3. Relaunch Chrome.
  4. Open chrome://components and ensure Optimization Guide On Device Model is fully downloaded.

๐ŸŽฏ Wrap Up

The Web platform is becoming intelligent by default. With Chrome's Built-in AI and the Summarizer API:

  • Your users get instant insights without paying API subscriptions.
  • Their sensitive data never leaves the browser.
  • You can build delightful, futuristic user experiences with minimal boilerplate.

Have you experimented with Gemini Nano in Chrome yet? Let me know your thoughts in the comments below! ๐Ÿ‘‡


Found this helpful? Star my portfolio on GitHub: vitorstick/vitor-space

Top comments (0)