@aventara/next
v1.0.0-rc.12
Published
Next.js App Router host integration for Aventara.
Readme
@aventara/next
Hosts an Aventara Framework in a Next.js App Router application (Next 16.4 or later 16.x, Node runtime). It serves the
framework's HTTP protocol (AvProtocol.bind(framework) from @aventara/core/protocol) from one catch-all route file.
Every rule of the protocol (routes, decoding, statuses, envelopes, headers, absence) is core's; this package only turns
a Web Request into the protocol's request and writes the protocol's answer back as a Web Response.
@aventara/core is its dependency; next (>=16.4 <17) and react (^19) are peers, and @aventara/mcp an
optional one (see MCP). The entry is server-only: a Client Component
importing it fails the Next.js build.
Setup
// src/aventara/server.ts
import "server-only";
import { Aventara } from "@aventara/next";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import { prisma } from "../prisma";
import adapter from "./adapter";
import { aventaraConfig } from "./config";
export const { handlers, avClient, avApp, ready } = Aventara({
...aventaraConfig, // api: "/api", resources, policy, ...
adapter: () => createPrismaAdapter({ ...adapter, client: prisma }),
state: async ({ headers }) => ({ user: await userFrom(headers) }), // your auth library
});// src/app/api/[...aventara]/route.ts (the folder matches api: "/api")
import { handlers } from "@/aventara/server";
export const { GET, POST, PUT, PATCH, DELETE } = handlers;| Option | What it is |
|---|---|
| everything createFramework takes | api, resources, policy, limits, prebuilt, ... checked exactly as createFramework checks them |
| adapter | the adapter, or a function that builds it (it may return a promise), called on the first request |
| state | ({ headers, request }) => state: seeds each request's ctx.$state (where policy.principal reads the user) |
| access | an access provider (see @aventara/core): its policy checks each request and its state seeds ctx.$state, handed the same { headers, request }. Not together with state or policy |
Or hand it a Framework you built, as createFramework resolved it (as AventaraModule.forRoot({ framework }) takes
one in NestJS), with state beside it (or none, when it was built with access):
const framework = await createFramework({ ...aventaraConfig, adapter: createPrismaAdapter({ ...adapter, client }) });
export const { handlers, avClient, avApp, ready } = Aventara({ framework, state });Every member works on that Framework, which is never built again: avClient.tx and avApp.appTx build handles at
once, and ready() checks it (a Resource named like a member, mcp without authorization) and registers the
universal path. A Framework and a configuration are two sources of one Framework, so the options carry one or the
other: a configuration key beside framework is a type error, and Aventara() refuses it when called, naming the
keys. So is a framework that is not one.
Aventara() returns at once and builds nothing: next build imports the route module, so the Framework (and the
adapter, when it is a function) is created on first use, or by ready(), once. A configuration createFramework
refuses rejects that use with its AvConstructionError.
| Member | What it is |
|---|---|
| handlers | { GET, POST, PUT, PATCH, DELETE } for the catch-all route file |
| avClient | the client layer for Server Components and Server Actions: framework.serverClient, with this request's state; avClient.tx and avClient.transaction([...]) |
| avApp | the application layer for trusted server code: framework.application; avApp.appTx and avApp.transaction([...]) |
| ready | () => Promise<void>: builds the Framework once; rejects with the construction error; registers the in-process path @/aventara/universal calls on the server |
handlers is typed AvNextHandlers, and avClient.transaction and avApp.transaction AvNextClientTransaction and
AvNextAppTransaction, all three exported, so a project that emits declarations (declaration: true) writes
export const { handlers, avClient, avApp, ready } = Aventara(...) with no annotation. AvNextStateContext is what
state is handed ({ headers, request? }), for a state function written in a file of its own:
export const state = async ({ headers }: AvNextStateContext) => ({ user: await userOf(headers) });.
Server Components and Server Actions
// src/app/campaigns/page.tsx
import { Suspense } from "react";
import { avClient } from "@/aventara/server";
async function Campaigns() {
const campaigns = await avClient.Campaign.find.many({
where: { startsAt: { gt: new Date() } },
select: ["id", "name", "startsAt"],
});
return <ul>{campaigns.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Campaigns />
</Suspense>
);
}avClient.<Resource>.<family>.<variant>(args, options?) is framework.serverClient: typed from the Client
Contract's view (what the client layer hides is absent), taking and answering application values (Date, bigint,
AvDecimal, Uint8Array). A call runs in process, with no HTTP: the same client pipelines, validation, exposure and
policy as a request to handlers, with the principal policy.principal reads from state. A refusal is thrown as
its AvError class (AvAuthError for the policy).
avClient.all({ key: call, … }, { settle?, signal? }) awaits an object of calls together, in process, as the
generated client's avClient.all does in the browser, so a component reads alike on both sides:
import { avLazy } from "@aventara/core";
const { campaigns, count } = await avClient.all({
campaigns: avClient.Campaign.find.many({ select: ["id", "name"] }),
count: avLazy(() => avClient.Campaign.find.count({})),
});A call runs when it is made, so all cannot pace it. Wrap a call in avLazy(() => …) to have all start it: at
most limits.maxAllConcurrency at once, and never once all has failed or been aborted. @aventara/core's avLazy
keeps no answer: each await makes the call again, so one made at module scope never hands one request's answer to
another. It takes any number of
entries: limits.maxAll bounds POST /_all only. Strict by default: the first
failure rejects it (fail-fast, not atomic). With settle: true each key is { status: "fulfilled", value } or
{ status: "rejected", reason }. Its signal aborts each entry; a call's own signal ({ signal } in its options)
aborts only that call. Nothing is aborted for you. A Resource named all is refused when the Framework is built.
state is called with { headers } from next/headers (there is no request outside a route call), once per
server render however many calls the render makes. Outside a render, in a Server Action's body for example, it runs
once per call. A call that names its own state in its options uses it as given. Without a state option, calls
start from {} and read no headers.
Request-time data
A call reads the database, so Next.js renders it at request time, never into the page next build prerenders. Every
avClient, avApp and @/aventara/universal call first awaits Next's io() (next/cache). Under Cache Components
(cacheComponents: true, which create-next-app sets), a page that calls one needs what any uncached database read
needs, one of:
<Suspense>around the component that calls it (or aloading.tsxbeside the page): the fallback is prerendered and the data streams in at request time, as above;"use cache"on a function that makes the call: the result is cached, and prerendered with the page;export const instant = falsein the page: it renders whole, at request time.
With none of them, next build fails with Next's own message, which names the three. In a request, inside
"use cache", in a Server Action, in an after() callback and without Cache Components the call runs at once.
With access, avClient.$access is framework.serverClient.$access with this request filled in: can([...]) is
answered under the request's state, and me(), signIn(...) and the other verbs the provider offers run Aventara
Access in process with the headers of next/headers. In a Server Action, Access sets a sign-in's cookie itself
(Better Auth's nextCookies). @/aventara/universal's $access does the same on the server, through what ready()
registers, and calls Access's own routes in the browser. $access holds the verbs the provider offers, read from the
options before the Framework is built, as serverClient.$access holds them. Calling another one rejects with
AvProtocolError A2002, naming why: no access, or a provider whose client does not offer that verb.
On Access's kiosk profile, call switchUser, lock and enrollDevice from a Server Action (or from the browser),
never while rendering a Server Component: each rotates the device's credential cookie, which Next lets Access set only
in a Server Action or a Route Handler, so a rotation during a render is lost and the device's next switch is refused. Never call a verb that
changes anything (signIn, signUp, signOut, resetPassword, and the kiosk's five) inside "use cache": a cached
result would replay one caller's answer to another and skip the call. Each verb's answer is projected onto its result
schema on the server too, so no session token or device secret reaches your code.
avApp is framework.application: trusted server code. It reads no request, and the policy checks a call only when
it names a principal ({ as: principal }).
Transactions keep their layer: avClient.tx builds client-layer handles (framework.serverClientTx) and
avClient.transaction([...]) runs them with this request's state; avApp.appTx builds application handles (and
avApp.appTx.$sql), and avApp.transaction([...]) runs them. A list that mixes the two is a type error, and refused
at runtime (A2004). Handles are built on the built Framework: await ready() before building them.
Startup
// src/instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { ready } = await import("@/aventara/server");
await ready();
}
}ready() builds the Framework and its routes, once: a second call returns the same promise. It rejects with the
construction error, so a bad configuration fails next start. It reads no request, so it runs where there is none
(instrumentation.ts, a script).
serverExternalPackages (required)
// next.config.ts
const nextConfig: NextConfig = {
serverExternalPackages: ["@aventara/core", "@aventara/prisma7-adapter"],
};Next.js bundles a package into each layer of the server (instrumentation, route handlers, Server Components) as a
copy of its own. Listed here, @aventara/core and the adapter are loaded by Node.js once per server process instead:
one core, so the Framework every layer shares (below) answers each layer with values and errors of the same classes
(AvDecimal, AvValidationError, ...), and one Prisma client. aventara new --host next and aventara init add the
line (an existing config they cannot edit safely gets it printed instead). @aventara/next itself stays bundled: it
relies on Next.js's server aliases (next/navigation, next/headers, server-only).
Next.js evaluates src/aventara/server.ts more than once in one server process (once for instrumentation.ts, again
for the routes and pages). Every copy that calls Aventara() with the same options shares one Framework, held per
api path for the process, so ready() in instrumentation.ts builds the one the routes use, with one adapter, and
avClient.tx works from the first request. Two instances on two api paths hold two. In production, a different
configuration on a path already held is refused when Aventara() is called; in development it replaces the held one,
as next dev evaluates the file again after an edit. Copies are told apart by the options' data (Next.js rewrites a
function's source in each layer). An edit that changes only a function, a guard's or a pipe's body or the adapter
factory, is told by each layer from the text that layer saw last: in development the first layer to see it builds the
Framework again, and the other layers join that build. A Framework given as { framework } is that call's own.
Once the Framework is built, ready() also registers the path @/aventara/universal's avClient takes on the server
(below). Without instrumentation.ts, that avClient refuses its calls on the server in one sentence.
Prefetch and hydrate: @/aventara/universal, avDehydrate, avHydrate
aventara-build --client writes src/aventara/universal.ts. Its avClient has the browser client's types and runs on
both sides: in the browser it calls /api; on the server it answers the same request in process, through the bound
protocol and with this request's state, so a query prefetched in a Server Component and refetched in the browser
gets the same values (Date, bigint, the client's own AvDecimal).
Its calls are the generated client's own on both sides: made when written, aborted by their own signal, and reached
by avClient.all's signal and strict abort and by avLazy. In the browser the calls made in one tick are grouped
into one /_all. On the server none is grouped: each call is sent on its own, from the async context of the code that
made it (its request, unstable_cache's scope, your own), so it runs with that scope's state; in process a group
would save no round trip. It exports avLazy beside avClient, for components that run on both sides. In the
browser an avLazy makes its call once and keeps the answer. On the server it keeps one answer per request inside a
render (React's cache), and none where React keeps no request, such as a route handler or a Server Action, where each
await makes the call again.
A decimal is a class instance, which React refuses to pass from a Server Component to a Client Component. avDehydrate
(this package, server side) turns each Date, bigint, Uint8Array and decimal into a tagged plain object, and
avHydrate (@/aventara/client, browser side) turns them back.
// src/app/notes/notes.queries.ts (server and browser)
import { queryOptions } from "@tanstack/react-query";
import { avClient } from "@/aventara/universal";
export const notesQuery = queryOptions({
queryKey: ["notes"],
queryFn: () => avClient.Note.find.many({ select: ["id", "title", "createdAt"] }),
});// src/app/notes/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { Suspense } from "react";
import { avDehydrate } from "@aventara/next";
import { notesQuery } from "./notes.queries";
import { NoteList } from "./note-list";
async function Notes() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(notesQuery);
return (
<HydrationBoundary state={dehydrate(queryClient, { serializeData: avDehydrate })}>
<NoteList />
</HydrationBoundary>
);
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<Notes />
</Suspense>
);
}// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { avHydrate } from "@/aventara/client";
const queryClient = new QueryClient({ defaultOptions: { hydrate: { deserializeData: avHydrate } } });
export const Providers = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);With SWR, pass avDehydrate({ [key]: data }) from the Server Component and avHydrate(fallback) to SWRConfig's
fallback in the Client Component.
Importing @/aventara/client instead of @/aventara/universal in shared code opts out of running on the server: its
calls are refused there.
Routes
handlers answers the protocol's routes under api: each operation route (POST /api/_resources/...),
GET /api/_contract (with its ETag, and 304 on If-None-Match) and POST /api/_transactions when the
ClientContract's transactions are interactive. Every method is exported so that a wrong method on an operation route
gets the protocol's 405 (A2012) with Allow. A path the protocol does not own goes to Next's notFound().
A request body is read up to limits.maxRequestBytes decoded bytes; gzip, deflate and br bodies are inflated,
and any other content coding is the protocol's 415 (A2011).
MCP
With mcp in the configuration, the same handlers also serve @aventara/mcp's endpoint and its OAuth
protected-resource metadata. Add one route file for each path:
// src/aventara/server.ts
export const { handlers } = Aventara({
...aventaraConfig,
mcp: {
path: "/mcp",
authorization: {
servers: ["https://auth.example.com"],
verify: async (token) => (await verifyAccessToken(token)) ?? null, // your token check
},
},
adapter: () => createPrismaAdapter({ ...adapter, client: prisma }),
});// src/app/mcp/route.ts
import { handlers } from "@/aventara/server";
export const { GET, POST, DELETE } = handlers;// src/app/.well-known/oauth-protected-resource/mcp/route.ts
import { handlers } from "@/aventara/server";
export const { GET } = handlers;Install @aventara/mcp at the same version as @aventara/core (npm install --save-exact @aventara/mcp@<version>).
It is an optional peer, loaded only when mcp is configured; when it is missing, the first request fails with a
sentence that names the install command.
On Next.js, mcp needs authorization. Without it, @aventara/mcp answers only callers on the same machine that
send no x-forwarded-for, and Next.js adds x-forwarded-for to every request, so every caller would be refused. An
mcp without authorization is a type error on the Aventara({...}) call, and, for a configuration typed wider than its literal, refused when the Framework is built (the first request), with a sentence that says so.
Everything the endpoint answers is @aventara/mcp's: the bearer check and its 401 with WWW-Authenticate, the
tools, the tool calls (each one an agent-origin protocol request, with state and the token's claims in
ctx.$state.mcp.claims), the 405 on GET and DELETE (the endpoint is stateless) and the metadata document. The
request body is read up to the Agent Contract's maxRequestBytes and handed on as sent.
Status
A prerelease, installed with its tag:
npm install @aventara/next@rc