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#

Terminal
# .env.local — confirm .env.local is in .gitignore
ZEEVAA_API_KEY=zv_live_…
ZEEVAA_ASSISTANT_ID=9f2b1c84-6e3a-4d17-b0c5-2e7a8f41d9b3

No 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.

TypeScript
// 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:

TypeScript
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/*/chat and sessions/*/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.
TypeScript
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.

TSX
// 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.

Terminal
npm install @ai-sdk/react
TSX
// 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.

TSX
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#

TSX
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:

nginx
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#