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>
  );
}