AgentDBDocs

Next.js

Build a contact list with the Next.js App Router. Route handlers run on the server and hold the AgentDB key. Client components call those route handlers.

Not run in CI. The code below is not run in the test suite. It makes the same AgentDB calls as the crm-typescript starter, which the smoke test runs (npm run docs:starters).

#Where the key lives

In .env.local on your machine, and in the project's environment variables on your host. Next.js sends every NEXT_PUBLIC_ variable to the browser. The key must never have that prefix.

Shell: wrong, do not copy
# Wrong: NEXT_PUBLIC_ puts the key in the JavaScript bundle for every visitor.
NEXT_PUBLIC_AGENTDB_TOKEN=adbk.your-key
Shell: secure, do this
# Secure: only server code (route handlers, server components) can read these.
AGENTDB_API_URL=https://app.agentdb.link
AGENTDB_WORKSPACE=ws_your_workspace_id
AGENTDB_TOKEN=replace-with-your-agent-key

#The AgentDB helper

Put the AgentDB call in one server-only module. The server-only package makes the build fail if a client component imports it.

TypeScript
// lib/agentdb.ts
import "server-only";

const API = process.env.AGENTDB_API_URL ?? "https://app.agentdb.link";

export async function agentdb<T>(method: string, path: string, body?: unknown): Promise<T> {
  const res = await fetch(`${API}/v1/workspaces/${process.env.AGENTDB_WORKSPACE}${path}`, {
    method,
    headers: { authorization: `Bearer ${process.env.AGENTDB_TOKEN}`, "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
    cache: "no-store",
  });
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.error?.message ?? `AgentDB answered ${res.status}`), { status: res.status, code: json.error?.code });
  return json.data as T;
}

#The backend route

TypeScript
// app/api/contacts/route.ts
import "server-only";
import { NextResponse, type NextRequest } from "next/server";
import { agentdb } from "@/lib/agentdb";

type Contact = { id: string; data: { name: string; email?: string } };

export async function GET(req: NextRequest) {
  const q = (req.nextUrl.searchParams.get("q") ?? "").trim().slice(0, 100);
  try {
    const data = await agentdb<{ records: Contact[] }>("POST", "/query", {
      object: "contact",
      where: { all: q ? [{ field: "name", op: "contains", value: q }] : [] },
      sort: [{ field: "name", order: "asc" }],
      limit: 50,
    });
    return NextResponse.json({ contacts: data.records.map((r) => ({ id: r.id, name: r.data.name, email: r.data.email ?? null })) });
  } catch {
    return NextResponse.json({ error: "The data service is not available." }, { status: 502 });
  }
}

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => ({}));
  const name = String(body.name ?? "").trim().slice(0, 200);
  if (!name) return NextResponse.json({ error: "A contact needs a name." }, { status: 400 });
  const rec = await agentdb<Contact>("POST", "/records", { object: "contact", data: { name } });
  return NextResponse.json({ contact: { id: rec.id, name: rec.data.name } }, { status: 201 });
}

A server component can also call agentdb() directly and render the list on the server. Never pass the key, or the raw AgentDB answer with fields you do not need, into a client component's props.

#The frontend

TSX
// app/contacts/contact-list.tsx
"use client";
import { useEffect, useState } from "react";

type Row = { id: string; name: string };

export function ContactList() {
  const [q, setQ] = useState("");
  const [rows, setRows] = useState<Row[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const ac = new AbortController();
    setRows(null);
    setError(null);
    fetch(`/api/contacts?q=${encodeURIComponent(q)}`, { signal: ac.signal })
      .then(async (res) => {
        const body = await res.json();
        if (!res.ok) throw new Error(body.error);
        setRows(body.contacts);
      })
      .catch((e) => e.name !== "AbortError" && setError(e.message));
    return () => ac.abort();
  }, [q]);

  return (
    <>
      <input type="search" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
      {error ? <p role="alert">Error: {error}</p> : rows === null ? <p>Loading…</p> : rows.length === 0 ? <p>No contacts found.</p> : null}
      <ul>{rows?.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
    </>
  );
}

#Loading, empty and error states

rows === null means loading. An empty array means "no results". An error replaces both. For server components, use loading.tsx for the loading state and error.tsx for errors.

#Live updates

Serverless functions stop after each request, so they cannot keep a change-feed loop running. Choose one:

  • Poll your route handler every 10 to 30 seconds with setInterval.
  • Create a read-only dashboard data link and long-poll it from the browser. It holds no key.
  • Run a long-lived Node process (for example on a VPS) for server-sent events, as in the live dashboard starter.

#Deploy

On Vercel or Netlify, add the three variables in the project settings. See Deployment.