Guides
Next.js integration
One proxy route, one hook, and a working chat interface — with your key safely on the server.
Your API key belongs on your server. Your interface runs in a browser. This guide bridges the two, once, in about thirty lines.
1. Store the key#
# .env.local — confirm .env.local is in .gitignore
ZEEVAA_API_KEY=zv_live_…
ZEEVAA_ASSISTANT_ID=9f2b1c84-6e3a-4d17-b0c5-2e7a8f41d9b3No NEXT_PUBLIC_ prefix. That prefix is what inlines a value into the browser
bundle, which is exactly what must not happen here.
2. Add the proxy route#
One catch-all handles every endpoint, streaming included.
// app/api/zeevaa/[...path]/route.ts
export const runtime = "nodejs";
export const maxDuration = 120;
const ZEEVAA = "https://studio.zeevaa.ai/api/v1";
async function forward(request: Request, path: string[]) {
const upstream = await fetch(
`${ZEEVAA}/${path.join("/")}${new URL(request.url).search}`,
{
method: request.method,
headers: {
authorization: `Bearer ${process.env.ZEEVAA_API_KEY!}`,
"content-type":
request.headers.get("content-type") ?? "application/json",
// Forwarded so the client can still choose its stream format.
...(request.headers.get("x-zeevaa-format")
? { "x-zeevaa-format": request.headers.get("x-zeevaa-format")! }
: {}),
},
body: request.method === "GET" ? undefined : await request.text(),
},
);
// Passing `upstream.body` through unread is what keeps streaming streaming.
// Calling .json() or .text() here would buffer the whole reply and every
// token would arrive at once.
return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type":
upstream.headers.get("content-type") ?? "application/json",
"cache-control": "no-store",
},
});
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
return forward(request, (await params).path);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
return forward(request, (await params).path);
}Your browser code now calls /api/zeevaa/assistants/…/chat and never sees the
key.
maxDuration matters. Without it your platform's default may cut off a turn
that searches a catalogue before answering.
3. Secure the proxy#
Warning
The route above is reachable by anyone who can load your site. As written, a stranger can run up your model and voice bill from a terminal. Add a check before you deploy it.
What that check is depends on your app. If you have sessions:
import { auth } from "@/lib/auth";
async function forward(request: Request, path: string[]) {
const session = await auth();
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// …as above
}If your assistant is genuinely public — a sales page, a help widget — you have no session to check, so bound the damage instead:
- Rate limit per IP. A few conversations an hour is generous for a real visitor and useless to a script.
- Restrict the paths. Allow
assistants/*/sessions,assistants/*/chatandsessions/*/end. Reject everything else, so nobody reads your transcripts or your usage through it. - Pin the assistant id from the environment rather than taking it from the request, so your proxy cannot be pointed at a different one.
const ALLOWED = [
/^assistants\/[\w-]+\/(sessions|chat|speech|transcription-token)$/,
/^sessions\/[\w-]+\/end$/,
];
if (!ALLOWED.some((pattern) => pattern.test(path.join("/")))) {
return Response.json({ error: "Not found" }, { status: 404 });
}4. Open a session on the server#
The session belongs to a page load, so create it in a server component and hand the id down. Your pre-chat form answers go with it.
// app/chat/page.tsx
export default async function ChatPage() {
const response = await fetch(
`https://studio.zeevaa.ai/api/v1/assistants/${process.env.ZEEVAA_ASSISTANT_ID}/sessions`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.ZEEVAA_API_KEY!}`,
"content-type": "application/json",
},
body: JSON.stringify({ channel: "chat" }),
cache: "no-store",
},
);
const session = await response.json();
return <Chat sessionId={session.session_id} greeting={session.greeting} />;
}5. The interface#
Because the default stream format is the AI SDK protocol, useChat works
against your proxy with no parsing.
npm install @ai-sdk/react// app/chat/chat.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
export function Chat({
sessionId,
greeting,
}: {
sessionId: string;
greeting: string;
}) {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
api: `/api/zeevaa/assistants/${process.env.NEXT_PUBLIC_ASSISTANT_ID}/chat`,
body: { session_id: sessionId },
});
const busy = status === "streaming" || status === "submitted";
return (
<div className="flex h-full flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<p className="text-muted-foreground">{greeting}</p>
{messages.map((message) => (
<div key={message.id} data-role={message.role}>
{message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")}
</div>
))}
</div>
<form
className="flex gap-2 border-t p-4"
onSubmit={(event) => {
event.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput("");
}}
>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask anything…"
className="flex-1"
/>
<button disabled={busy}>{busy ? "…" : "Send"}</button>
</form>
</div>
);
}The assistant id is not a secret — it is useless without a key — so exposing it
as NEXT_PUBLIC_ASSISTANT_ID is fine. The key is the thing that stays server
side.
6. Render catalogue results#
Text is the least interesting thing the assistant returns. Catalogue matches
arrive as a data part with the id canvas, carrying real records.
const canvas = messages
.flatMap((message) => message.parts)
.findLast((part) => part.type === "data-canvas")?.data;
if (canvas?.kind === "results") {
return (
<div className="grid grid-cols-2 gap-4">
{canvas.status === "loading"
? Array.from({ length: canvas.expected ?? 4 }).map((_, index) => (
<SkeletonCard key={index} />
))
: canvas.records.map((record) => (
<PropertyCard key={record.id} record={record} />
))}
</div>
);
}findLast is doing real work: the panel shows the most recent search, not the
first one. And painting expected skeletons while status is loading keeps
the layout from jumping when the real cards land.
This is the payoff for using the API rather than the embed. Your cards, your grid, your design — with retrieval you did not have to build.
7. End the session#
useEffect(() => {
const end = () => {
navigator.sendBeacon(`/api/zeevaa/sessions/${sessionId}/end`);
};
window.addEventListener("pagehide", end);
return () => window.removeEventListener("pagehide", end);
}, [sessionId]);sendBeacon survives the page closing, which fetch does not. pagehide
rather than beforeunload — the latter is unreliable on mobile Safari.
Best effort by nature. Someone who loses connection never sends it, which is why a quiet session is closed for you anyway.
Deploying#
Vercel. Works as written. Set ZEEVAA_API_KEY in project settings, not in a
committed file. Keep maxDuration on the proxy route.
Node, Docker, anywhere else. Also works as written. Confirm your reverse proxy does not buffer responses — nginx buffers by default, which turns a stream into a single delayed blob:
location /api/zeevaa/ {
proxy_buffering off;
proxy_read_timeout 120s;
}Troubleshooting#
Everything arrives at once. Something is buffering. Check you are passing
upstream.body through rather than awaiting .json(), and check your reverse
proxy.
401 from the proxy but the key works in curl. The variable is not reaching
the process. console.log(process.env.ZEEVAA_API_KEY?.slice(0, 12)) in the
route — a NEXT_PUBLIC_ prefix or a missing restart is the usual cause.
Replies cut off after a few seconds. maxDuration is missing, or a reverse
proxy read timeout is too short.
409 session_expired. The conversation outlived max_call_seconds. Open a
new session.
Next#
- Custom chat UI — the same thing without the AI SDK
- Voice — adding speech