Building a Streaming URL Summarizer with Astro, Datastar, and the Vercel AI SDK
I wanted a dead-simple tool: paste a URL, get a clean Markdown summary streamed into the page. No page reloads, no React, no client-side state library. Just Astro on the server, Datastar for reactivity, and the Vercel AI SDK for the model call.
Here's how the pieces fit together.
The shape of it
There are two files:
- An Astro page that renders the form and a target div for the streaming output.
- An API route that extracts the URL's content, calls the model, and pushes Markdown chunks back over Server-Sent Events.
That's the whole thing. No client framework, no build-time hydration island, no fetch glue code.
The page
The page declares its state up front as Datastar signals, binds an input to url, and points a button at the action endpoint.
---
import Layout from '@/layouts/Layout.astro'
const initialSignals = JSON.stringify({
url: '',
_loading: false,
_error: '',
_streamChunk: '',
})
---
<Layout title="TLDR">
<section class="max-w-3xl mx-auto p-4" data-signals__ifmissing={initialSignals}>
<h1 class="text-3xl font-bold mb-2">TLDR</h1>
<p class="opacity-70 mb-6">Paste a URL and get an AI-generated summary.</p>
<input
type="text"
class="input input-bordered w-full mb-3"
placeholder="https://example.com/article"
data-bind="url"
/>
<button
class="btn btn-primary"
data-on:click="$_loading = true; $_error = ''; @get('/summarize')"
data-attr:disabled="$_loading || !$url.trim()">
<span data-show="!$_loading">Summarize</span>
<span data-show="$_loading">Summarizing…</span>
</button>
<div data-show="$_error" class="alert alert-error mt-4">
<span data-text="$_error"></span>
</div>
<div
id="summary"
class="prose max-w-none mt-6"
data-on-signal-patch-filter="{include: /^_streamChunk$/}"
data-on-signal-patch="window.__appendChunk?.(el, patch._streamChunk)">
</div>
</section>
</Layout>
<script>
import { default_renderer, parser, parser_write, parser_end } from 'streaming-markdown'
let smd: any = null
window.__appendChunk = (el: HTMLElement, chunk: string | undefined) => {
if (chunk == null) return
if (chunk === '__START__') { el.replaceChildren(); smd = parser(default_renderer(el)); return }
if (chunk === '__END__') { if (smd) parser_end(smd); smd = null; return }
if (smd) parser_write(smd, chunk)
}
</script>
A few things worth pointing out:
data-signals__ifmissingseeds the reactive state without clobbering it on re-renders.data-bind="url"keeps the input two-way bound to theurlsignal.@get('/summarize')tells Datastar to open an SSE connection to the action route, automatically sending the current signals along.- The output div uses
data-on-signal-patchto react every time the server patches_streamChunk. We hand each chunk tostreaming-markdown, which renders Markdown incrementally as tokens arrive — no flicker, no full re-parse on every chunk.
The action route
The API route is where Datastar meets the Vercel AI SDK. Datastar's SDK gives us a ServerSentEventGenerator that handles the SSE protocol; the AI SDK's streamText gives us an async iterator of text deltas. We just bridge the two.
We also need a tiny extract helper that fetches the page and pulls out the readable text. Cheerio is enough for the 80% case — strip the chrome, prefer <article>/<main>, fall back to <body>.
// src/pages/summarize.ts
import type { APIRoute } from 'astro'
import { ServerSentEventGenerator } from '@starfederation/datastar-sdk/web'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import * as cheerio from 'cheerio'
async function extract(url: string): Promise<{ title: string; text: string }> {
const res = await fetch(url, {
headers: { 'user-agent': 'Mozilla/5.0 (compatible; TLDRBot/1.0)' },
signal: AbortSignal.timeout(15_000),
})
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`)
const html = await res.text()
const $ = cheerio.load(html)
// Strip chrome and non-content elements
$('script, style, nav, header, footer, aside, noscript, iframe, form, [role="navigation"], [role="banner"], [role="complementary"]').remove()
const title = $('meta[property="og:title"]').attr('content')?.trim()
|| $('title').text().trim()
|| $('h1').first().text().trim()
|| url
// Prefer semantic main-content containers, fall back to body
const candidates = ['article', 'main', '[role="main"]', '.post-content', '.entry-content', '.article-content']
let node = $('body')
for (const sel of candidates) {
const el = $(sel).first()
if (el.length && el.text().trim().length > 200) { node = el; break }
}
const text = node.text().replace(/\s+/g, ' ').trim().slice(0, 20_000)
if (!text) throw new Error('No readable content found at URL.')
return { title, text }
}
export const GET: APIRoute = async ({ request }) => {
const reader = await ServerSentEventGenerator.readSignals(request)
if (!reader.success) return new Response('Bad request', { status: 400 })
const url = ((reader.signals as any).url || '').trim()
if (!url) {
return ServerSentEventGenerator.stream(async (stream) => {
stream.patchSignals(JSON.stringify({ _loading: false, _error: 'Please enter a URL.' }))
})
}
return ServerSentEventGenerator.stream(async (stream) => {
try {
const article = await extract(url)
stream.patchSignals(JSON.stringify({ _streamChunk: '__START__' }))
const response = streamText({
model: openai('gpt-5'),
system: 'You are a concise summarizer. Output clean Markdown.',
prompt: `Summarize the following article in ~300 words:\n\n${article.text}`,
abortSignal: AbortSignal.timeout(120_000),
})
let pending = ''
let i = 0
for await (const delta of response.textStream) {
pending += delta
if (++i % 5 === 0 || delta.includes('\n')) {
stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
pending = ''
}
}
if (pending) stream.patchSignals(JSON.stringify({ _streamChunk: pending }))
stream.patchSignals(JSON.stringify({ _streamChunk: '__END__' }))
} catch (err: any) {
stream.patchSignals(JSON.stringify({ _error: err.message || 'Something went wrong.' }))
} finally {
stream.patchSignals(JSON.stringify({ _loading: false }))
stream.close()
}
}, { keepalive: true })
}
The interesting bits:
extractis intentionally tiny: fetch with a timeout, strip chrome with cheerio, prefer<article>/<main>, cap the text at 20k chars so we don't blow the model's context. For sites that need real readability heuristics you'd reach for Mozilla's Readability, but this handles most blogs and news sites just fine.readSignals(request)pulls the current signal state out of the request that Datastar sent — so we geturlserver-side without writing any form-handling code.streamTextfrom the Vercel AI SDK gives us an async iterator. We don't have to deal with raw SSE from the model provider — the SDK normalizes that.- We batch chunks every 5 tokens (or at newlines) before patching. Patching on every single token works, but it's wasteful — small batches feel just as smooth and cut SSE traffic dramatically.
- The sentinel chunks
__START__and__END__let the client reset and finalize the streaming Markdown parser cleanly.
Why this combo works
I've built variations of this with React + a custom useChat hook, with htmx, and with raw EventSource. The Astro + Datastar + Vercel AI SDK version is the one I keep coming back to, because:
- Astro stays static where it can. The page is server-rendered HTML. There's no client framework runtime — Datastar is ~15kb and handles all the reactivity declaratively.
- Datastar's SSE story is first-class.
@get+patchSignalsmeans I never write afetchcall or anEventSourcelistener. The server pushes signal updates and the DOM reacts. - The Vercel AI SDK abstracts the model. Swapping
openai('gpt-4o')foranthropic('claude-sonnet-4-6')is a one-line change. The streaming interface stays identical. - Streaming Markdown rendering turns the raw token stream into a progressively-rendered article instead of a wall of half-formed syntax.
The whole thing is maybe 200 lines of code across both files. No state management library, no client router, no API client. Just a form, an SSE stream, and a Markdown renderer — exactly the amount of machinery the problem deserves.