Streaming AI Responses in Angular with the Vercel AI SDK
Learn how to use the Vercel AI SDK with Angular to stream LLM responses, generate structured data, and handle loading and error states with signals.
TL;DR: The Vercel AI SDK (
aipackage) gives you a provider-agnostic abstraction for streaming text, generating structured objects, and handling tool calls. Pair it with Angular signals and you get reactive streaming UI without wrestling with raw SSE or fetch streams.
Integrating LLMs into Angular apps usually starts with a raw fetch call to the OpenAI REST API. That works fine for one-shot completions, but the moment you want streaming, you are manually reading a ReadableStream, splitting on newline delimiters, parsing JSON chunks, and updating state by hand — in a way that does not play well with Angular's change detection.
The Vercel AI SDK (the ai npm package, v4+) solves this. It is provider-agnostic (works with OpenAI, Anthropic, Google, Mistral, and more), has first-class streaming support, and gives you clean primitives for structured output generation. Combined with Angular signals, you can build a streaming AI chat widget in under 100 lines with no manual stream parsing.
The Problem: Manual SSE Parsing in Angular is Painful
Before reaching for the SDK, this is what streaming an OpenAI response typically looks like in Angular:
// the manual approach — fragile and verbose
streamCompletion(prompt: string) {
fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
}).then(async (res) => {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunk = decoder.decode(value);
// parse "data: {...}\n\n" manually
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
const json = line.slice(6).trim();
if (json === '[DONE]') return;
const delta = JSON.parse(json).choices[0].delta.content ?? '';
this.text.update(t => t + delta);
}
}
}
});
}
This breaks in subtle ways: incomplete JSON chunks across read boundaries, missed [DONE] markers, and no error boundary. With the Vercel AI SDK, all of this disappears.
Setting Up the Vercel AI SDK
Install the SDK and the provider package for whichever model you are using:
npm install ai @ai-sdk/openai zod
The ai package is the core SDK. Provider packages (@ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google, etc.) are separate so you only ship what you use.
Because LLM API keys must never reach the browser, all AI SDK calls go through a backend route. If you do not have a Node.js backend, a lightweight Angular proxy to an Express or Nitro server works fine. The pattern below uses a simple Nitro / Analog.js API route, but the Angular side is identical regardless of your backend.
A typical backend handler:
// server/routes/chat.post.ts (Nitro / Analog.js)
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
const ChatRequestSchema = z.object({
prompt: z.string().min(1).max(4000),
});
const rateLimiter = new Map<string, number>();
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization');
if (authHeader !== `Bearer ${process.env.AI_API_TOKEN}`) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
const ip = getRequestHeader(event, 'x-forwarded-for') ?? 'unknown';
const now = Date.now();
const lastRequest = rateLimiter.get(ip) ?? 0;
if (now - lastRequest < 1000) {
throw createError({ statusCode: 429, statusMessage: 'Rate limit exceeded' });
}
rateLimiter.set(ip, now);
const body = await readBody(event);
const parsed = ChatRequestSchema.safeParse(body);
if (!parsed.success || parsed.data.prompt.trim().length === 0 || parsed.data.prompt.length > 4000) {
throw createError({ statusCode: 400, statusMessage: 'Invalid request body' });
}
try {
const result = streamText({
model: openai('gpt-4o-mini'),
prompt: parsed.data.prompt,
});
return result.toTextStreamResponse();
} catch {
throw createError({ statusCode: 502, statusMessage: 'Model request failed' });
}
});
toTextStreamResponse() returns a plain Response whose body is the raw generated text as it streams in — no custom protocol to parse, and no risk of the client and server drifting apart across SDK versions.
Consuming the Stream in Angular with Signals
On the Angular side, consume the response body directly with the standard ReadableStream reader — no SDK-specific parsing needed, and no risk of the client and server drifting apart across ai package versions, since toTextStreamResponse() has kept the same plain-text body shape across SDK generations:
import {
Component,
signal,
computed,
ChangeDetectionStrategy,
} from '@angular/core';
@Component({
selector: 'app-ai-chat',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<textarea
[value]="prompt()"
(input)="prompt.set($any($event.target).value)"
placeholder="Ask anything…"
></textarea>
<button (click)="send()" [disabled]="loading()">
{{ loading() ? 'Streaming…' : 'Send' }}
</button>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<pre class="output">{{ text() }}</pre>
`,
})
export class AiChatComponent {
prompt = signal('');
text = signal('');
loading = signal(false);
error = signal<string | null>(null);
wordCount = computed(() => {
const trimmed = this.text().trim();
return trimmed ? trimmed.split(/\s+/).length : 0;
});
async send() {
this.text.set('');
this.error.set(null);
this.loading.set(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: this.prompt() }),
});
if (!response.ok || !response.body) {
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
this.text.update(t => t + decoder.decode(value, { stream: true }));
}
// flush any buffered multi-byte characters split across the final chunk boundary
this.text.update(t => t + decoder.decode());
} catch (e: unknown) {
this.error.set(e instanceof Error ? e.message : 'Something went wrong');
} finally {
this.loading.set(false);
}
}
}
Each iteration of the while loop runs synchronously between microtasks, so every signal.update() call is captured by Angular's reactive graph. In zoneless mode this works without any additional setup; in zone-based apps it also works because signals trigger their own scheduling.
If you need typed events for tool calls or usage metadata rather than plain text, swap toTextStreamResponse() for the newer UI message stream response your installed SDK version exposes, and consume it with that version's matching client-side reader — check the Vercel AI SDK documentation for the exact export names, since those have moved between major versions.
Generating Structured Objects with generateObject
Streaming unstructured text covers most chat use-cases, but sometimes you need structured data — a product recommendation, a JSON config, a plan with typed fields. The AI SDK's generateObject constrains the model output to a Zod schema and validates the result automatically:
Define the schema once in a module both the server route and the Angular client can import — not inside the server route file itself, which client code shouldn't reach into directly across the network boundary:
// shared/plan.schema.ts
import { z } from 'zod';
export const PlanSchema = z.object({
title: z.string(),
steps: z.array(z.object({
order: z.number(),
description: z.string(),
estimatedMinutes: z.number(),
})),
});
export type Plan = z.infer<typeof PlanSchema>;
// server/routes/generate-plan.post.ts
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { PlanSchema } from '../../shared/plan.schema';
export default defineEventHandler(async (event) => {
const { goal } = await readBody(event);
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: PlanSchema,
prompt: `Create a concise action plan for: ${goal}`,
});
return object; // fully typed, schema-validated before returning
});
On the Angular side, you fetch that endpoint and assign the result directly to a typed signal:
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';
import { PlanSchema, type Plan } from '../shared/plan.schema';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input #goalInput placeholder="Your goal" />
<button (click)="generate(goalInput.value)">Build Plan</button>
@if (plan(); as p) {
<h2>{{ p.title }}</h2>
@for (step of p.steps; track step.order) {
<div class="step">
<span class="order">{{ step.order }}</span>
<p>{{ step.description }}</p>
<small>~{{ step.estimatedMinutes }} min</small>
</div>
}
}
`,
})
export class PlanGeneratorComponent {
plan = signal<Plan | null>(null);
error = signal<string | null>(null);
async generate(goal: string) {
this.error.set(null);
try {
const res = await fetch('/api/generate-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goal }),
});
if (!res.ok) {
this.plan.set(null);
this.error.set(`Plan request failed with ${res.status}`);
return;
}
let body: unknown;
try {
body = await res.json();
} catch {
this.plan.set(null);
this.error.set('Plan response was not valid JSON.');
return;
}
const parsed = PlanSchema.safeParse(body);
this.plan.set(parsed.success ? parsed.data : null);
if (!parsed.success) {
this.error.set('Plan response did not match the expected schema.');
}
} catch {
this.plan.set(null);
this.error.set('Plan generation failed.');
}
}
}
The server already validates the object against PlanSchema before returning it, but the client should not trust that blindly — a network error, a proxy, or a future change to the endpoint could still hand the component malformed JSON. Re-running PlanSchema.safeParse() on the client before the signal.set() call means a bad response degrades to null (rendered as no plan) instead of crashing the template on a missing field.
Switching Providers Without Touching Angular Code
The main practical benefit of the SDK's provider abstraction is that swapping models is a one-line change on the server. To move from OpenAI to Anthropic:
npm install @ai-sdk/anthropic
// before
import { openai } from '@ai-sdk/openai';
const model = openai('gpt-4o-mini');
// after — Angular component unchanged
import { anthropic } from '@ai-sdk/anthropic';
const model = anthropic('claude-haiku-4-5-20251001');
The Angular component does not change at all because it only reads the plain text stream, not the provider API. This also means you can run A/B tests or fallback logic entirely in the server handler.
Wrapping Up
The Vercel AI SDK removes the most tedious part of Angular AI integration — stream parsing — and replaces it with a plain Response body that slots naturally into signals. Install ai and a provider package, add a one-line backend route returning toTextStreamResponse(), then consume it with a standard ReadableStream reader in your component. The Vercel AI SDK documentation covers all providers and streaming primitives. Try replacing any raw streaming fetch call in your Angular app with this pattern today and see the code shrink while the type safety improves.
Frequently Asked Questions
Does the Vercel AI SDK require Vercel hosting?
No. The ai npm package is a standalone library with no Vercel infrastructure dependency. You can run it on any Node.js server — Express, Nitro, Analog.js API routes, Fastify, or a plain Node HTTP server. The name reflects where the project originated, but the package is fully open-source and works anywhere Node.js runs.
How do I cancel the stream when the user navigates away in Angular?
Pass an AbortSignal to fetch and tie it to Angular's DestroyRef — the effect() cleanup guide covers DestroyRef patterns in depth. Create an AbortController in the component, pass signal: controller.signal to the fetch options, and call controller.abort() inside a DestroyRef.onDestroy() callback. The AI SDK propagates the abort signal through to the underlying provider request, which stops generation and saves token costs.
Can I use the AI SDK without a backend proxy?
For local prototyping you can call provider APIs directly from the browser by importing the provider package client-side, but this exposes your API key in the JavaScript bundle. For any production or shared deployment, always route AI calls through a backend endpoint so the key stays server-side. A minimal Express or Nitro handler adds very little overhead.
Is reading the stream with ReadableStream.getReader() compatible with Angular's OnPush change detection?
Yes. Each await reader.read() resolves as a microtask and calls signal.update() synchronously within that tick. Signals trigger their own fine-grained scheduling in Angular, so OnPush components re-render correctly on each chunk without needing ChangeDetectorRef.markForCheck() or NgZone.run().
Related reading: