ai-streaming
Implement end-to-end AI response streaming in Next.js with Vercel AI SDK, aborts, errors, and loading states. Use when building chat or generation features.
- Category
- ai
- Package
- ai-streaming/SKILL.md
- License
- MIT
- Author
- @tushaarmehtaa
- Tags
- aistreamingvercel-ai-sdkssechatgenerationllm
Install
Swipe for more runtimes.
Codex
Skills directory: ~/.codex/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ai-streaming -g -a codex -yInvoke
$ai-streaming or /skillsYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
Claude Code
Skills directory: ~/.claude/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ai-streaming -g -a claude-code -yInvoke
/ai-streamingYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
Cursor
Skills directory: ~/.cursor/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ai-streaming -g -a cursor -yInvoke
/ai-streamingYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
local coding agent required
This skill requires project files, terminal commands, and network access. Uploading it to a chat app does not provide equivalent execution.
ChatGPT Skills
This workflow needs a local coding environment or capabilities that a chat-only Skills upload does not provide.
Why local agent required →Instructions
Source: SKILL.mdWire streaming AI responses from model to browser. Uses the Vercel AI SDK — the right abstraction for Next.js AI apps. Reads the project first, wires into existing auth and model config.
Four things that silently break AI streaming
- Wrong runtime on the route handler. Streaming requires the Edge runtime or a Node.js runtime with proper response handling. Without
export const runtime = 'edge'or proper streaming setup, the response buffers entirely and "streams" all at once at the end. - No abort handling. If the user navigates away mid-generation, the model keeps running and spending tokens. Always pass
abortSignalfrom anAbortControllerand clean up on component unmount. - Auth check after the stream starts. Once you call
streamText, headers are sent. You can't return a 401 after streaming begins. Auth must be checked and validated before calling any SDK method. - Missing error boundaries on the client. If the stream errors mid-response,
useChatsurfaces it in theerrorstate — but if you don't render it, the UI freezes silently with partial output.
Phase 1: Detect the Project
cat package.json | grep -E "ai|anthropic|openai|@ai-sdk"
aipackage present? → Vercel AI SDK installed, go to Phase 3@anthropic-ai/sdkonly? → add Vercel AI SDK on topopenaionly? → add Vercel AI SDK on top- Nothing? → install from scratch
Check for existing AI routes:
find . -name "*.ts" -path "*/api/*" | xargs grep -l "streamText\|createOpenAI\|anthropic" 2>/dev/null
Phase 2: Install
npm install ai @ai-sdk/anthropic
# or for OpenAI:
npm install ai @ai-sdk/openai
Add to .env.example:
ANTHROPIC_API_KEY=
# or
OPENAI_API_KEY=
Phase 3: The Route Handler
Create the streaming API route. Auth check must come first — before any SDK call:
// app/api/chat/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const runtime = 'edge'; // required for true streaming
export async function POST(req: Request) {
// 1. Auth — must happen before streamText
const user = await getAuthUser(req);
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Credits / rate limit check (if applicable)
if (user.credits <= 0) {
return Response.json({ error: 'No credits remaining', upgradeUrl: '/pricing' }, { status: 402 });
}
// 3. Parse the request
const { messages } = await req.json();
// 4. Stream
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
system: 'You are a helpful assistant.',
maxTokens: 1024,
abortSignal: req.signal, // propagate client abort
onFinish: async ({ usage }) => {
// Deduct credits after completion
await deductCredits(user.id, usage.totalTokens);
},
});
return result.toDataStreamResponse();
}
For single-turn generation (not chat):
// app/api/generate/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const runtime = 'edge';
export async function POST(req: Request) {
const user = await getAuthUser(req);
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const { prompt } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
prompt,
abortSignal: req.signal,
});
return result.toDataStreamResponse();
}
Phase 4: The Client Hook
Chat interface (multi-turn):
'use client';
import { useChat } from 'ai/react';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
api: '/api/chat',
onError: (err) => console.error('Chat error:', err),
});
return (
<div>
<div>
{messages.map((m) => (
<div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
<p>{m.content}</p>
</div>
))}
{isLoading && <p className="text-muted animate-pulse">Thinking...</p>}
{error && <p className="text-red-500">Something went wrong. Try again.</p>}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={stop}>Stop</button>
) : (
<button type="submit">Send</button>
)}
</form>
</div>
);
}
Single-turn generation:
'use client';
import { useCompletion } from 'ai/react';
export function Generator() {
const { completion, input, handleInputChange, handleSubmit, isLoading, error, stop } = useCompletion({
api: '/api/generate',
});
return (
<div>
<form onSubmit={handleSubmit}>
<textarea value={input} onChange={handleInputChange} />
<button type="submit" disabled={isLoading}>Generate</button>
{isLoading && <button type="button" onClick={stop}>Stop</button>}
</form>
{error && <p className="text-red-500">{error.message}</p>}
{completion && <div className="whitespace-pre-wrap">{completion}</div>}
</div>
);
}
Phase 5: Streaming Loading States
Three states to handle, all distinct:
// 1. Not started — show the input
// 2. Loading (model thinking, no tokens yet) — show spinner
// 3. Streaming (tokens arriving) — show partial text
function StreamingOutput({ isLoading, completion }: { isLoading: boolean; completion: string }) {
if (!isLoading && !completion) return null;
return (
<div>
{isLoading && !completion && (
// Model is thinking — no tokens yet
<div className="flex gap-1">
<span className="animate-bounce">·</span>
<span className="animate-bounce delay-100">·</span>
<span className="animate-bounce delay-200">·</span>
</div>
)}
{completion && (
// Tokens arriving
<p className="whitespace-pre-wrap">
{completion}
{isLoading && <span className="animate-pulse">▋</span>}
</p>
)}
</div>
);
}
Phase 6: Wire Credits Into the Stream
If the project uses a credits system created with the ship-credits skill, deduct after the stream finishes — not before:
// In the route handler onFinish callback:
onFinish: async ({ usage, finishReason }) => {
if (finishReason === 'stop' || finishReason === 'length') {
const cost = Math.ceil(usage.totalTokens / 100); // define your credit cost
await db.update(users)
.set({ credits: sql`credits - ${cost}` })
.where(eq(users.id, user.id));
}
},
Never deduct before the stream — if the model errors on token 1, the user lost credits for nothing.
Verify
[ ] Route handler exports runtime = 'edge'
[ ] Auth check happens before any streamText call
[ ] req.signal passed as abortSignal to streamText
[ ] useChat/useCompletion renders error state (not just isLoading)
[ ] Stop button shown when isLoading — lets user cancel
[ ] Credits deducted in onFinish, not before the stream
[ ] Partial text renders during stream (not only after complete)
[ ] Test: start generation, navigate away — confirm model call aborts in server logs
[ ] Test: trigger an error — confirm error state renders, not a frozen UI