Building a Streaming URL Summarizer with Astro, Datastar, and the Vercel AI SDK

Roger Stringer Roger Stringer
April 06, 2026
8 min read
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:

  1. An Astro page that renders the form and a target div for the streaming output.
  2. 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__ifmissing seeds the reactive state without clobbering it on re-renders.
  • data-bind="url" keeps the input two-way bound to the url signal.
  • @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-patch to react every time the server patches _streamChunk. We hand each chunk to streaming-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:

  • extract is 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 get url server-side without writing any form-handling code.
  • streamText from 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 + patchSignals means I never write a fetch call or an EventSource listener. The server pushes signal updates and the DOM reacts.
  • The Vercel AI SDK abstracts the model. Swapping openai('gpt-4o') for anthropic('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.

Tagged In: AI Code

Do you like my content?

Sponsor Me On Github