I recently wanted to add a summarization feature to my personal engineering blog so readers could get quick key takeaways or a TL;DR before diving into a long post.
The traditional approach would be setting up an API route, managing an OpenAI or Anthropic API key, handling rate limits, and paying per token. But Chrome (starting with version 138) now exposes built-in, on-device AI capabilities powered by Gemini Nano.
One of those capabilities is the Summarizer API. Because inference runs locally in the browser:
- There are no API keys or backend servers to maintain.
- User data never leaves their machine.
- It costs nothing to run.
- It works offline once the model weights are downloaded.
Here is how I implemented it in React and TypeScript, along with how to handle streaming, model download states, and graceful degradation for browsers that do not support the API yet.
1. Getting TypeScript Support
Chrome's Built-in AI APIs follow the emerging W3C Web Incubator Community Group (WICG) specifications. To avoid typing (window as any) everywhere, install the official community definitions:
npm install -D @types/dom-chromium-ai
Then add dom-chromium-ai to your tsconfig.app.json (or tsconfig.json):
{
"compilerOptions": {
"types": ["vite/client", "dom-chromium-ai"]
}
}
This pulls in the global declarations for Summarizer, CreateMonitor, Availability, and related types.
2. Feature Detection and Graceful Degradation
Since this is currently a Chromium-specific capability behind flags or in newer versions, your code needs to detect whether the API is available and degrade gracefully. If a reader visits your site on Firefox, Safari, or an unsupported Chrome build, the feature should simply not appear rather than throwing runtime errors.
The API exposes an availability() check. Depending on the runtime state, it returns:
-
'available'/'readily': The model is on disk and ready to summarize immediately. -
'downloadable'/'after-download': The browser supports the API, but the Gemini Nano model needs to download first. -
'downloading': The model is actively downloading. -
'unavailable'/'no': The device or browser does not meet requirements.
Here is a helper module to handle detection and compatibility:
// src/lib/summarizer.ts
/// <reference types="dom-chromium-ai" />
export type SummarizerStyleType = 'key-points' | 'tldr' | 'teaser' | 'headline';
export type SummarizerLengthType = 'short' | 'medium' | 'long';
export type AvailabilityStatus =
| 'readily'
| 'after-download'
| 'available'
| 'downloadable'
| 'downloading'
| 'unavailable'
| 'no'
| 'unsupported';
export function getSummarizerFactory() {
if (typeof window === 'undefined') return null;
const win = window as any;
const workerSelf = typeof self !== 'undefined' ? (self as any) : null;
return (
win.ai?.summarizer ||
workerSelf?.ai?.summarizer ||
win.Summarizer ||
workerSelf?.Summarizer ||
null
);
}
export async function checkSummarizerSupport(
options?: SummarizerCreateOptions
): Promise<AvailabilityStatus> {
try {
const factory = getSummarizerFactory();
if (!factory) return 'unsupported';
if (typeof factory.availability === 'function') {
return (await factory.availability(options)) as AvailabilityStatus;
}
if (typeof factory.capabilities === 'function') {
const caps = await factory.capabilities();
return (caps?.available as AvailabilityStatus) || 'unsupported';
}
return 'unsupported';
} catch {
return 'unsupported';
}
}
export function isUsableStatus(status: AvailabilityStatus): boolean {
return ['readily', 'available', 'after-download', 'downloadable', 'downloading'].includes(status);
}
3. Session Creation, Download Monitoring, and Streaming
When creating a summarizer instance via create(), you can configure:
-
type:'key-points','tldr','teaser', or'headline' -
format:'markdown'or'plain-text' -
length:'short','medium', or'long' -
monitor: A callback receiving aCreateMonitorobject to track download progress if the browser needs to fetch model weights first.
The summarizeStreaming() method returns a readable stream. Iterating over the stream yields incremental text deltas, allowing you to update state as words appear.
// src/lib/summarizer.ts
export interface GenerateSummaryParams {
content: string;
type: SummarizerStyleType;
length: SummarizerLengthType;
sharedContext?: string;
onChunk?: (accumulatedText: string, delta: string) => void;
onDownloadProgress?: (percentage: number) => void;
signal?: AbortSignal;
}
export async function runSummarization({
content,
type,
length,
sharedContext,
onChunk,
onDownloadProgress,
signal,
}: GenerateSummaryParams): Promise<string> {
const factory = getSummarizerFactory();
if (!factory) {
throw new Error('Summarizer API is not available.');
}
const createOptions: SummarizerCreateOptions = {
type,
format: 'markdown',
length,
outputLanguage: 'en',
sharedContext: sharedContext || 'Technical software article',
signal,
monitor: (monitor: CreateMonitor) => {
monitor.addEventListener('downloadprogress', (e: ProgressEvent) => {
if (e.total && e.total > 0) {
const percent = Math.min(100, Math.round((e.loaded / e.total) * 100));
onDownloadProgress?.(percent);
} else if (e.loaded > 0) {
onDownloadProgress?.(Math.min(99, Math.round(e.loaded * 100)));
}
});
},
};
const instance = await factory.create(createOptions);
try {
if (signal?.aborted) {
throw new Error('Aborted by user.');
}
if (typeof instance.summarizeStreaming === 'function') {
const stream = instance.summarizeStreaming(content, { signal });
let accumulated = '';
// Handle async iterable stream
if (stream && typeof (stream as any)[Symbol.asyncIterator] === 'function') {
for await (const delta of stream as AsyncIterable<string>) {
if (signal?.aborted) break;
accumulated += delta;
onChunk?.(accumulated, delta);
}
return accumulated;
}
// Handle ReadableStream with Reader
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;
}
}
// Non-streaming fallback
const result = await instance.summarize(content, { signal });
onChunk?.(result, result);
return result;
} finally {
instance.destroy();
}
}
4. Building the React Component
Here is the React component. It checks availability on mount. If the browser does not support on-device AI, it returns null so the page layout remains untouched.
When available, it renders an interactive drawer allowing the user to select their preferred summary style and length, displays a download bar if the model is loading, and streams the output directly into the UI.
// src/components/ArticleSummarizer.tsx
import React, { useState, useEffect, useRef } from 'react';
import {
checkSummarizerSupport,
isUsableStatus,
runSummarization,
type SummarizerStyleType,
type SummarizerLengthType,
type AvailabilityStatus,
} from '../lib/summarizer';
interface Props {
content: string;
articleTitle?: string;
}
const TYPE_OPTIONS: { id: SummarizerStyleType; label: string }[] = [
{ id: 'key-points', label: 'Key Points' },
{ id: 'tldr', label: 'TL;DR' },
{ id: 'teaser', label: 'Teaser' },
{ id: 'headline', label: 'Headline' },
];
const LENGTH_OPTIONS: { id: SummarizerLengthType; label: string }[] = [
{ id: 'short', label: 'Short' },
{ id: 'medium', label: 'Medium' },
{ id: 'long', label: 'Long' },
];
export const ArticleSummarizer: React.FC<Props> = ({ content, articleTitle }) => {
const [availability, setAvailability] = useState<AvailabilityStatus | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [type, setType] = useState<SummarizerStyleType>('key-points');
const [length, setLength] = useState<SummarizerLengthType>('medium');
const [summary, setSummary] = useState('');
const [loading, setLoading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let active = true;
void checkSummarizerSupport().then((status) => {
if (active) setAvailability(status);
});
return () => { active = false; };
}, []);
// Return nothing if the client does not support the API
if (!availability || !isUsableStatus(availability)) {
return null;
}
const handleGenerate = async () => {
if (loading) return;
setError(null);
setSummary('');
setDownloadProgress(null);
setLoading(true);
setIsOpen(true);
const controller = new AbortController();
abortRef.current = controller;
try {
await runSummarization({
content,
type,
length,
sharedContext: articleTitle ? `Article: ${articleTitle}` : undefined,
signal: controller.signal,
onDownloadProgress: (pct) => setDownloadProgress(pct),
onChunk: (accumulated) => {
setSummary(accumulated);
setDownloadProgress(null);
},
});
} catch (err: any) {
if (!controller.signal.aborted) {
setError(err?.message || 'An error occurred during summarization.');
}
} finally {
setLoading(false);
setDownloadProgress(null);
}
};
const handleStop = () => {
abortRef.current?.abort();
setLoading(false);
};
const handleCopy = () => {
void navigator.clipboard.writeText(summary);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="border border-neutral-800 bg-neutral-950 rounded-xl p-5 text-neutral-200 my-6">
<div className="flex items-center justify-between">
<div>
<span className="text-xs font-mono font-semibold uppercase text-emerald-400">
Local AI Summarizer
</span>
<p className="text-xs text-neutral-400 mt-0.5">
Summarize this article on-device using Chrome's built-in model.
</p>
</div>
<div className="flex items-center gap-2">
{!summary && !loading && (
<button
onClick={() => void handleGenerate()}
className="px-3.5 py-1.5 rounded-lg bg-emerald-400 text-black text-xs font-medium hover:bg-emerald-300 transition-colors"
>
Summarize Article
</button>
)}
{loading && (
<button
onClick={handleStop}
className="px-3 py-1.5 rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-xs"
>
Stop
</button>
)}
<button
onClick={() => setIsOpen(!isOpen)}
className="px-2.5 py-1.5 rounded-lg border border-neutral-800 text-xs text-neutral-400 hover:text-neutral-200"
>
{isOpen ? 'Hide' : 'Options'}
</button>
</div>
</div>
{isOpen && (
<div className="mt-4 pt-4 border-t border-neutral-900 space-y-4">
<div className="flex flex-wrap gap-4 text-xs font-mono">
<div className="flex items-center gap-2">
<span className="text-neutral-500">STYLE:</span>
{TYPE_OPTIONS.map((opt) => (
<button
key={opt.id}
disabled={loading}
onClick={() => setType(opt.id)}
className={`px-2.5 py-1 rounded border transition-colors ${
type === opt.id
? 'border-emerald-400/80 bg-emerald-950/40 text-emerald-300'
: 'border-neutral-800 text-neutral-400 hover:text-neutral-200'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-neutral-500">LENGTH:</span>
{LENGTH_OPTIONS.map((opt) => (
<button
key={opt.id}
disabled={loading}
onClick={() => setLength(opt.id)}
className={`px-2 py-1 rounded border transition-colors ${
length === opt.id
? 'border-emerald-400/80 bg-emerald-950/40 text-emerald-300'
: 'border-neutral-800 text-neutral-400 hover:text-neutral-200'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{downloadProgress !== null && (
<div className="space-y-1.5 text-xs font-mono text-emerald-400">
<div className="flex justify-between">
<span>Downloading model weights...</span>
<span>{downloadProgress}%</span>
</div>
<div className="w-full bg-neutral-900 h-1.5 rounded-full overflow-hidden">
<div
className="bg-emerald-400 h-full transition-all duration-200"
style={{ width: `${downloadProgress}%` }}
/>
</div>
</div>
)}
{error && (
<p className="text-xs font-mono text-red-400 bg-red-950/30 p-3 rounded-lg border border-red-900/50">
{error}
</p>
)}
{summary && (
<div className="space-y-3">
<div className="p-4 bg-neutral-900/70 border border-neutral-800 rounded-lg text-sm text-neutral-200 leading-relaxed whitespace-pre-wrap">
{summary}
</div>
<div className="flex items-center justify-between text-xs font-mono">
<button
onClick={() => void handleGenerate()}
disabled={loading}
className="text-neutral-400 hover:text-neutral-200"
>
Regenerate
</button>
<button
onClick={handleCopy}
className="px-3 py-1.5 rounded border border-neutral-800 text-neutral-300 hover:text-white"
>
{copied ? 'Copied' : 'Copy Summary'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
};
5. Testing Locally in Chrome
If you want to test this during local development:
- Use Chrome Canary, Dev, or Beta (version 138 or newer).
- Navigate to
chrome://flagsin the address bar and enable:- Enables optimization guide on device: Set to Enabled BypassPrefRequirement.
- Summarization API for Gemini Nano: Set to Enabled.
- Restart Chrome.
- Go to
chrome://componentsand click Check for update under Optimization Guide On Device Model to ensure the model binaries are fetched.
Conclusion
The ability to run Gemini Nano directly in the browser opens up a lot of practical use cases without incurring infrastructure costs or dealing with backend latency.
By checking availability upfront and supporting streaming and download events, you can build a seamless progressive enhancement for your readers while ensuring non-Chromium users continue to have an uninterrupted reading experience.
Top comments (0)