Mayura with Next.js
Serve Mayura's agent API from a Next.js route handler, stream runs into React components, and run durable workflows next to your app.
4 min read
Mayura's server is a standard fetch(request) handler, so a Next.js app can serve it from a single route handler. There
is no separate API server to deploy, and no proxy between your pages and your agents: the browser calls
/v1/runs on your own origin, and Mayura streams the run back to a React component.
This guide covers a Next.js app that runs on a Node.js server with next start. For Vercel, the same module works
behind the setup in Deploy Mayura on Vercel.
What you'll build
- A route handler that serves Mayura's API at
/v1/*, with your app's sign-in deciding who may run which agent. - A client component that starts a run, shows each tool as it runs and streams the reply as it's written.
- A worker process for durable workflows, if your agents start any.
1. Install
Next.js already brings React, so Mayura is the only addition:
npm install mayura2. Define the server once
Put the server in its own module, so the route handler stays a thin adapter. mounted: true tells Mayura that your
framework routed the request to it: it trusts only the path and query it was given, never the Host header.
// src/mayura.ts
import { createAgentServer } from 'mayura/server';
export const api = createAgentServer({
publicOrigin: 'https://app.example.com',
mounted: true,
agents,
authenticate: async ({ token }) => {
const session = await verifySession(token);
if (!session) return null;
return {
scope: { principalId: session.userId, projectId: 'web' },
agentIds: ['support.assistant'],
capabilities: ['runs:submit', 'runs:read'],
expiresAtMs: Math.min(session.expiresAtMs, Date.now() + 60_000),
};
},
});agents is the list of agents you serve, each with the permissions and limits it runs under; see
Agent and Permissions. verifySession is
your app's own check. Mayura never parses the token: authenticate decides who the caller is, which agents they may
use and what they may do, and a caller can't choose any of it. Returning null answers 401.
Keep principalId to letters, digits, ., _, / and -. If your identity provider uses subjects such as
auth0|123, map them, for example to a SHA-256 digest. The
capabilities table lists everything a
caller can be allowed.
3. Add the route handler
One catch-all route under app/v1/ sends every API request to the handler:
// app/v1/[...path]/route.ts
import { api } from '@/src/mayura';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const GET = (request: Request) => api.fetch(request);
export const POST = (request: Request) => api.fetch(request);runtime = 'nodejs' keeps it off the edge runtime. dynamic = 'force-dynamic' stops Next.js from caching API
responses. The API stays at /v1/ on your public origin, which is where mayura/client expects it.
4. Show a live run in React
In the browser, a headless store follows the run's events, and two hooks turn them into state you can render. This component submits a question, shows each tool as it runs, streams the reply's text and then shows the final reply:
'use client';
import { useEffect, useState } from 'react';
import { z } from 'mayura';
import { createClient } from 'mayura/client';
import { createHeadlessRunStore, type HeadlessRunStore } from 'mayura/client/headless';
import { useMayuraRun, useMayuraRunActivity } from 'mayura/client-react';
const Reply = z.object({ reply: z.string() });
const client = createClient({ baseUrl: `${window.location.origin}/`, token: () => sessionToken() });
function RunView({ store }: { readonly store: HeadlessRunStore }) {
const state = useMayuraRun(store);
const activity = useMayuraRunActivity(state);
return (
<section>
<p>{state.connection === 'reconnecting' ? 'Reconnecting…' : state.snapshot?.status ?? state.connection}</p>
<ul>
{activity.items.filter(item => item.kind === 'tool').map(item => <li key={item.id}>{item.label}: {item.status}</li>)}
</ul>
{state.streamedOutput && <p>{state.streamedOutput.text}</p>}
</section>
);
}
export function Ask() {
const [store, setStore] = useState<HeadlessRunStore | null>(null);
const [reply, setReply] = useState<string | null>(null);
useEffect(() => () => store?.dispose(), [store]);
async function ask(message: string): Promise<void> {
const run = await client.submit('support.assistant', { message }, { idempotencyKey: crypto.randomUUID() });
const next = createHeadlessRunStore({ run });
setStore(next);
setReply(null);
await next.observe();
const outcome = await run.result(Reply);
setReply(outcome?.status === 'succeeded' ? outcome.output.reply : 'Something went wrong.');
}
return (
<div>
<button onClick={() => void ask('Where is my order?').catch(() => setReply('Something went wrong.'))}>Ask</button>
{store && <RunView store={store} />}
{reply && <p>{reply}</p>}
</div>
);
}sessionToken() returns the token your sign-in gives the browser: the same one verifySession checks on the server.
Some details matter:
- The idempotency key makes submitting safe to retry. If the network drops after the server accepted the run, the same key finds that run instead of starting a second one.
- Nothing fetches on its own. Mounting the component or calling a hook never starts a request;
observe()does, and it reconnects a dropped stream from the last event it saw. - The streamed text is a preview.
run.result()returns the validated final output, and only that counts. If a stream guard stops the preview,streamedOutput.withheldis set and the text stops growing. - Clean up. Disposing the store stops following the run. It doesn't cancel the run.
For styled building blocks, mayura/client-react/components has unstyled, accessible components for a run summary,
workflow graphs and human response forms; see React and UI bindings.
5. Run workflows next to the app
A route handler only serves requests. If your agents start durable workflows,
something has to advance them: run mayura worker as a second process next to next start, and run
mayura migrate once per release, before either starts. Both load an application module that exports your worker and
migration; see Serve, worker and migrate.
npx mayura migrate --app dist/src/app.js
npx mayura worker --app dist/src/app.jsAgents that don't start workflows need neither.
Good to know
- Keep the route on the Node.js runtime unless you need the edge. Since 1.1, Mayura also runs on the Edge runtime, apart from its Node-only entry points; an Edge route needs storage over a pool the runtime allows and runs that finish inside their request. See Edge runtimes.
- One server instance per process. Create
apionce, at module level as above, not inside the route handler: runs in progress, streams and limits belong to the instance. - Several instances need shared run records. When more than one process serves
/v1/*, givecreateAgentServerrunRecordsbacked by PostgreSQL, so any instance can read a run another one started; see Several server replicas. - The repository runs this setup in CI.
examples/embedded-handler.mjsmounts the handler the same way and runs it end to end.
Next steps
- Deploy Mayura on Vercel: the same app on Vercel Functions, with workflows advanced by cron.
- Streaming: stream an output field as it's written.
- Build a refund agent with human approval: an agent that starts a durable workflow.