Examples
Build a chatbot with Meridian
A complete streaming chatbot in under 100 lines. Token-by-token rendering via native ReadableStream, zero client-side SDK dependencies.
Route handler
A single POST endpoint that streams raw tokens. No JSON framing — the client reads the response body as a byte stream.
// app/api/chat/route.ts
import { meridian } from "@/lib/meridian";
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = await meridian.chat.stream({
model: "meridian-3-flash",
messages,
temperature: 0.7,
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}
Client component
A single "use client" component. Uses the Fetch API with a ReadableStream reader and TextDecoder to render each chunk as it arrives. AbortController for cancellation.
// components/chat.tsx
"use client";
import { useState, useRef, useEffect } from "react";
interface Message {
role: "user" | "assistant";
content: string;
}
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [token, setToken] = useState("");
const abortRef = useRef<AbortController | null>(null);
const send = async () => {
if (!input.trim() || streaming) return;
const userMsg: Message = { role: "user", content: input };
const updated = [...messages, userMsg];
setMessages(updated);
setInput("");
setStreaming(true);
setToken("");
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: updated }),
signal: controller.signal,
});
if (!res.ok) throw new Error(res.statusText);
const reader = res.body?.getReader();
if (!reader) throw new Error("No reader");
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
full += chunk;
setToken(chunk);
}
setMessages((prev) => [...prev, { role: "assistant", content: full }]);
setToken("");
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error(err);
} finally {
setStreaming(false);
abortRef.current = null;
}
};
const stop = () => abortRef.current?.abort();
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
return (
<div className="flex flex-col h-[600px] rounded-2xl border border-violet-500/20 bg-[#0A0612]/80 backdrop-blur-xl overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((m, i) => (
<div
key={i}
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
m.role === "user"
? "ml-auto bg-violet-500/20 text-violet-100"
: "mr-auto bg-white/5 text-white/90"
}`}
>
{m.content}
</div>
))}
{token && (
<div className="max-w-[80%] mr-auto rounded-2xl px-4 py-2.5 text-sm bg-white/5 text-white/90 animate-pulse">
{token}
</div>
)}
</div>
<div className="border-t border-violet-500/20 p-3 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Ask anything..."
disabled={streaming}
className="flex-1 bg-white/5 border border-violet-500/20 rounded-xl px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-violet-400/50 transition-colors disabled:opacity-40"
/>
{streaming ? (
<button
onClick={stop}
className="rounded-xl bg-pink-500/20 border border-pink-400/30 px-4 py-2.5 text-sm font-medium text-pink-300 hover:bg-pink-500/30 transition-colors"
>
Stop
</button>
) : (
<button
onClick={send}
disabled={!input.trim()}
className="rounded-xl bg-violet-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-400 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Send
</button>
)}
</div>
</div>
);
}