10xGraph with Next.js: Build a Streaming Agent Frontend

In shortHow to call a 10xGraph Python agent from a Next.js frontend with streaming, auth, and typed responses. Server actions, route handlers, and 10xGraph.

  • 4 min read
  • 9 sections
  • Updated
  • v0.9.2
  • Markdown

A Python agent backend + a Next.js frontend is the most common production stack we see. 10xGraph’s typed TypeScript client (@10xscale/agentflow-client) is built for this.

Architecture

plaintext
[ Next.js (App Router) ]
   ├── React UI (client component)
   └── Route Handler / Server Action (Node runtime)
              │
              ▼ HTTP / SSE
[ 10xGraph API ] ← deployed separately
              │
              ▼
[ Postgres + Redis (PgCheckpointer) ]

The Next.js app does not run Python. It calls 10xGraph’s HTTP API.

Install

Terminal
npm install @10xscale/agentflow-client

Do not call the 10xGraph API directly from the browser. Your API key would leak. Proxy through a Next.js Route Handler:

TypeScript
// app/api/agent/stream/route.ts
import {NextRequest} from "next/server";
import {AgentFlowClient, Message} from "@10xscale/agentflow-client";
import {auth} from "@/lib/auth";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const user = await auth();
  if (!user) return new Response("Unauthorized", {status: 401});

  const {text} = await req.json();

  const client = new AgentFlowClient({
    baseUrl: process.env.AGENTFLOW_URL!,
    headers: {Authorization: `Bearer ${process.env.AGENTFLOW_API_KEY}`},
  });

  const stream = client.stream(
    [Message.text_message(text)],
    {config: {thread_id: `user-${user.id}`}, recursion_limit: 25},
  );

  // Convert AsyncIterable to ReadableStream and re-emit as SSE
  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of stream) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
        }
        controller.enqueue(encoder.encode(`event: done\ndata: {}\n\n`));
      } catch (e) {
        controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({message: String(e)})}\n\n`));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "X-Accel-Buffering": "no",
    },
  });
}

Note: this runs on the Node runtime, not the Edge. The Python backend connection benefits from Node’s longer-lived sockets.

Client-side hook

A small React hook for consuming the stream:

TSX
"use client";

import {useState, useCallback} from "react";

// Mirrors StreamChunk from @10xscale/agentflow-client. `event` is one of
// StreamEventType: "message" | "updates" | "state" | "error".
type Chunk = {
  event: "message" | "updates" | "state" | "error";
  message?: {role: string; content: Array<Record<string, unknown>>} | null;
  state?: Record<string, unknown> | null;
  data?: Record<string, unknown>;
  thread_id?: string;
  run_id?: string;
};

// `message.content` is an array of content blocks; concatenate the text ones.
// (The Message class has a `text()` method, but chunks that crossed the proxy
// as JSON are plain objects.)
const chunkText = (chunk: Chunk) =>
  (chunk.message?.content ?? [])
    .filter((b) => b.type === "text")
    .map((b) => String(b.text ?? ""))
    .join("");

export function useAgentStream() {
  const [output, setOutput] = useState("");
  const [streaming, setStreaming] = useState(false);
  const [status, setStatus] = useState("");

  const send = useCallback(async (text: string) => {
    setOutput("");
    setStreaming(true);
    try {
      const r = await fetch("/api/agent/stream", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({text}),
      });
      if (!r.ok || !r.body) throw new Error("stream failed");

      const reader = r.body.getReader();
      const decoder = new TextDecoder();
      let buf = "";

      for (;;) {
        const {value, done} = await reader.read();
        if (done) break;
        buf += decoder.decode(value, {stream: true});

        // Split SSE events on "\n\n"
        const events = buf.split("\n\n");
        buf = events.pop() ?? "";
        for (const ev of events) {
          const dataLine = ev.split("\n").find((l) => l.startsWith("data: "));
          if (!dataLine) continue;
          const json = dataLine.slice(6);
          if (json === "{}") continue;
          const chunk = JSON.parse(json) as Chunk;
          if (chunk.event === "message") {
            setOutput((s) => s + chunkText(chunk));
          } else if (chunk.event === "updates") {
            // Run-lifecycle marker, e.g. {status: "completed"}.
            setStatus(String(chunk.data?.status ?? ""));
          } else if (chunk.event === "error") {
            // The server never raises mid-stream; it emits this chunk instead.
            throw new Error(String(chunk.data?.reason ?? "stream error"));
          }
        }
      }
    } finally {
      setStreaming(false);
    }
  }, []);

  return {output, streaming, status, send};
}

Usage in a chat component:

TSX
"use client";
import {useAgentStream} from "./useAgentStream";

export default function Chat() {
  const {output, streaming, status, send} = useAgentStream();
  return (
    <div>
      <button onClick={() => send("Weather in Tokyo?")} disabled={streaming}>
        Ask
      </button>
      {status ? <small>{status}</small> : null}
      <pre>{output}</pre>
    </div>
  );
}

Server Actions (alternative)

For non-streaming use, Server Actions are slightly cleaner:

TypeScript
// app/actions/agent.ts
"use server";
import {AgentFlowClient, Message} from "@10xscale/agentflow-client";
import {auth} from "@/lib/auth";

const client = new AgentFlowClient({
  baseUrl: process.env.AGENTFLOW_URL!,
  headers: {Authorization: `Bearer ${process.env.AGENTFLOW_API_KEY}`},
});

export async function ask(text: string) {
  const user = await auth();
  if (!user) throw new Error("Unauthorized");

  const result = await client.invoke(
    [Message.text_message(text)],
    {config: {thread_id: `user-${user.id}`}},
  );
  return result.messages.at(-1)?.text() ?? "";
}

Server Actions do not stream though. For chat-style UIs, use the route handler pattern above.

Auth

Two common shapes:

  1. Pass through. Validate the user in Next.js, send a Bearer token to 10xGraph (server-to-server API key) and use thread_id = user.id.
  2. JWT forwarding. Validate the user, mint a per-request JWT, send to 10xGraph, 10xGraph re-validates. Useful when 10xGraph runs in a different security domain.

See Auth and authorization.

Common gotchas

  • Edge runtime does not support long-lived streams in some configurations. Default to runtime = "nodejs" for SSE.
  • Vercel function timeouts. The default is 10s. SSE streams need a longer timeout. Use maxDuration in your route or move the agent to a long-running deployment.
  • CORS. If you call 10xGraph from a different origin in dev, configure CORS on the 10xGraph side.
  • Browser EventSource lacks header support. Always use fetch + manual SSE parsing or @microsoft/fetch-event-source.

Hosting Next.js with a long-running agent

Vercel’s serverless functions are not great for long-running agent streams. Common alternatives:

  • Vercel + 10xGraph on a non-Vercel host. Next.js handles the UI; 10xGraph runs on AWS / Fly / Railway.
  • Self-host Next.js. next start on the same infra as 10xGraph. Works fine.
  • Cloudflare Workers + Durable Objects. Possible but extra plumbing.

For non-streaming, vanilla Vercel is fine.

Further reading

Last updated for v0.9.2Edit this page on GitHubReport an issue