Nuxt
Ship a full-stack app from one process: your Vue pages and your Heximon API served by the same Nuxt server,
sharing the same request. During server-side rendering, Nuxt forwards the browser's cookies to your
in-process Heximon routes automatically — so a signed-in visitor's personalized HTML is rendered on the
server, before a single byte of JavaScript runs in the browser. You add one module to nuxt.config.ts and
write your controllers under server/; there's no separate API host, no proxy, and no compile script.
Install the module
@heximon/nuxt runs Heximon inside Nuxt's own Nitro server. Install it and the HTTP layer you'll
write controllers against:
pnpm add @heximon/nuxt @heximon/http
npm install @heximon/nuxt @heximon/http
yarn add @heximon/nuxt @heximon/http
@nuxt/kit is an optional peer — a real Nuxt 5 app already pulls it in through nuxt, so you don't install
it yourself.
Register the Nuxt module
Add the module's string specifier to modules — it takes no inline options. Config lives in
heximon.config.ts at the project root, the same file every Heximon host reads: describe what
to compile via plugins — here just HttpPlugin, since this app is HTTP-only.
export default defineNuxtConfig({
compatibilityDate: "2025-07-04",
srcDir: "app",
modules: ["@heximon/nuxt"],
});
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin()], // add new EventEmitterPlugin(), … to compile those tiers too
});
That's the entire integration — no compile step, no compile script, no alias, no compiler import in app
code. When Nuxt builds, the module reads heximon.config.ts, compiles your server/ directory with the
listed plugins, and wires the resulting routes onto Nitro's HTTP layer. The Vite host uses a heximon()
plugin fed by the same file; here, adding the string to modules does the same job declaratively.
Share a contract between the server and the client
Nuxt already resolves one directory from both sides of the split: shared/. That makes it the natural home
for a Contract — the class the server's controller binds to and the Vue app constructs directly as its
typed client, later on this page, with no hand-written request/response types on either side.
import { Contract, Route } from "@heximon/contract";
import { profileSchema, signInResultSchema, signInSchema, signOutResultSchema } from "./schemas";
export class GreetingApi extends Contract({
prefix: "/api",
routes: {
signIn: Route.post("/sign-in").body(signInSchema).responses({ 200: signInResultSchema }),
signOut: Route.post("/sign-out").responses({ 200: signOutResultSchema }),
profile: Route.get("/profile").responses({ 200: profileSchema }),
},
}) {}
signInSchema and the response schemas are just Standard Schema validators — any library works; see
Validation & DTOs. The contract's own prefix: "/api" owns the whole
/api/* subtree, so nothing about the mount point lives in Nuxt config.
Write a controller under server/
Heximon modules and controllers live under Nuxt's server/ directory — the convention Nuxt already uses for
server code — while your Vue pages live under srcDir. The split is deliberate: app/ is the frontend
bundle, server/ is everything that runs only on the server, and the compiler scans the latter.
implements Controller<GreetingApi> binds the controller to the contract above: it reads the route table
off the type argument, so there's no host mount prefix to configure, and each handler is typed per route
with Action<GreetingApi, "key">.
import type { Action, Controller } from "@heximon/http";
import { GreetingApi } from "../../shared/greeting/contract";
import { GreetingService } from "./greeting.service";
export class GreetingController implements Controller<GreetingApi> {
constructor(private readonly greetings: GreetingService) {}
public async signIn(action: Action<GreetingApi, "signIn">) {
const payload = await action.request.readValidatedBody();
action.response.setCookie(GreetingService.visitorCookieName, encodeURIComponent(payload.name), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24,
});
return { name: payload.name };
}
public async profile(action: Action<GreetingApi, "profile">) {
const rawCookie = action.request.getCookie(GreetingService.visitorCookieName);
if (rawCookie === undefined || rawCookie === "") {
return this.greetings.signedOutProfile();
}
return this.greetings.signedInProfile(decodeURIComponent(rawCookie));
}
}
signIn validates the request body against the contract's own signInSchema before the handler runs — an
invalid body is rejected with a 400 application/problem+json before your code runs. This is exactly the
controller you'd write for the Vite or Nitro hosts; nothing about it is Nuxt-specific.
Declare the module
A module lists what it owns. The GreetingService is a plain provider; the controller is named under the
HTTP plugin's namespace. The compiler reads this config plus the controller's constructor and generates the
wiring — there's no runtime container resolving anything by reflection.
import { Module } from "@heximon/runtime";
import { GreetingController } from "./greeting.controller";
import { GreetingService } from "./greeting.service";
export class GreetingModule extends Module({
providers: [GreetingService],
http: { controllers: [GreetingController] },
}) {}
A root module under server/ composes your feature modules with imports, and the compiler picks it up as
the app — the same root-module convention every Heximon host uses.
import { Module } from "@heximon/runtime";
import { GreetingModule } from "./greeting/greeting.module";
export class AppModule extends Module({
imports: [GreetingModule],
}) {}
The full project then has a clean front/back split, with shared/ bridging the two:
app/ # Vue frontend (srcDir)
├── app.vue # <NuxtPage /> shell — pages mode
├── plugins/
│ └── vue-query.ts # installs TanStack Vue Query (SSR dehydrate/hydrate)
└── pages/
├── index.vue # useFetch/$fetch against /api/*
└── typed.vue # the typed contract client + vue-query (see below)
shared/ # Nuxt's app-and-server directory — the contract lives here
└── greeting/
├── schemas.ts # Standard Schema validators
└── contract.ts # GreetingApi extends Contract({ prefix: "/api", routes: {...} })
server/ # Heximon modules — compiled by the Nuxt module
├── app.module.ts
└── greeting/
├── greeting.module.ts
├── greeting.controller.ts
└── greeting.service.ts
nuxt.config.ts
Call the API from a Vue page
The frontend calls your routes with Nuxt's own useFetch / $fetch — they hit the in-process Heximon API on
the same server, so there's no base URL and no CORS to think about. The payoff lands during SSR: when
useFetch runs on the server, Nuxt forwards the incoming request's cookies to your Heximon route, so the
signed-in greeting is computed server-side and ships inside the initial HTML.
<script setup lang="ts">
type Profile = { signedIn: true; name: string; greeting: string } | { signedIn: false };
const name = ref("Alice");
// Runs during SSR: Nuxt forwards the request's cookies to the in-process Heximon API,
// so a signed-in visitor's greeting is already in the HTML when the page loads.
const { data: profile, refresh } = await useFetch<Profile>("/api/profile");
async function signIn(): Promise<void> {
await $fetch("/api/sign-in", { method: "POST", body: { name: name.value } });
await refresh();
}
</script>
To see that the work really happened on the server, sign in, then reload the page with JavaScript disabled in DevTools — the personalized greeting is still there, because it came from the cookie during SSR, not from a client-side fetch. That's the whole reason to host the API in-process: shared cookies, no auth round-trip to a separate origin, and authenticated state in the first paint.
useFetch/$fetch is the simple default — the next section reads the exact same routes through the typed
contract client instead.
Call the API with the typed contract client
GreetingApi isn't only a server binding — the Vue app can construct it directly as its client
(new GreetingApi(transport), from @heximon/client), so request and response types come from the
contract, with nothing hand-written and nothing to keep in sync when a route changes.
pnpm add @heximon/client @tanstack/vue-query
npm install @heximon/client @tanstack/vue-query
yarn add @heximon/client @tanstack/vue-query
@tanstack/vue-query is only needed for the reactive vue-query bindings below — the plain typed call
needs just @heximon/client. See REST Client for the full ClientTransport /
raw() / interceptor reference; this section covers what's Nuxt-specific: SSR cookie forwarding, and how the
typed client interacts with Nuxt's own data fetching.
ClientTransport.fetch takes only { baseUrl?, fetch? } — no header option — so forwarding the visitor's
cookie during SSR rides the contract's own ClientOptions.headers (the second constructor argument)
instead:
import { ClientTransport } from "@heximon/client";
import { GreetingApi } from "../../shared/greeting/contract";
const requestUrl = useRequestURL();
const forwardedCookie = useRequestHeaders(["cookie"]).cookie;
const transport = ClientTransport.fetch({ baseUrl: requestUrl.origin });
const clientOptions = forwardedCookie ? { headers: { cookie: forwardedCookie } } : undefined;
const api = new GreetingApi(transport, clientOptions);
useRequestURL() gives an absolute origin on both server and client — a plain fetch() can't resolve a
relative URL during SSR the way a browser resolves one against the page location. useRequestHeaders returns
{} on the client, so the cookie forwarding is a no-op there — the browser's own fetch() already carries
cookies for a same-origin request.
SSR-aware, with useAsyncData. Nuxt's own data-fetching composable accepts any async function, not only
$fetch, so it carries the typed call's result through the SSR payload exactly like the previous section's
useFetch — but typed end-to-end through GreetingApi:
const { data: ssrProfile, refresh: refreshSsrProfile } = await useAsyncData("greeting-profile-typed", () =>
api.client.profile(),
);
Client-reactive, with @heximon/client/vue-query. VueQuery.initClient builds one TanStack Vue Query
binding per contract route off the same contract instance — reusing api above keeps the whole page to one
GreetingApi instance:
const vueQuery = VueQuery.initClient(api);
const reactiveProfile = vueQuery.profile.useQuery(["greeting", "profile"]);
const signIn = vueQuery.signIn.useMutation({ onSuccess: refreshBothProfiles });
const signOut = vueQuery.signOut.useMutation({ onSuccess: refreshBothProfiles });
refreshBothProfiles is your own callback — it just calls reactiveProfile.refetch() and the
useAsyncData section's refreshSsrProfile() together, so a sign-in/sign-out mutation updates both sections
of the page at once. app/plugins/vue-query.ts installs VueQueryPlugin the standard Nuxt way — one QueryClient, dehydrated
into the Nuxt payload on app:rendered, rehydrated on app:created — which is unrelated to Heximon itself
and needed only so the bindings above have a QueryClient to run against.
useQuery doesn't hook into Vue's SSR data-prefetch (onServerPrefetch) the way Nuxt's
own composables do, so reactiveProfile above resolves after hydration, on the client. That's a real
difference, not a bug: reach for vue-query when you want client-managed cache, mutations, and invalidation
(the sign-in/sign-out buttons here); reach for useFetch/useAsyncData — or the plain typed call earlier on
this page — when the data has to be in the very first SSR response.The dev loop
nuxt dev gives you the Heximon dev experience with no extra setup — the module wires it in automatically
(and only in dev; production builds carry none of it):
- Recompile on change. Edit anything under
server/— add a route, change a module's providers — and the compiler re-runs; the browser reloads itself once the rebuilt server is live, no restart and no manual refresh. - Plain build errors. While a compile error stands, every request answers with the diagnostic — a codeframe page in the browser, plain text for an API client — instead of a confident response from stale wiring. Fix the source and the app recovers on its own.
- JSON preview. Navigate to an API route in the browser (
/api/profile) and the JSON renders as a pretty-printed, auto-reloading preview page; API clients and the Vue pages are untouched.
Under the hood this is @heximon/nitro's dev plugin (the same one a plain Nitro app lists in its
vite.config.ts) pushed into Nuxt's Vite dev server by the module — you never register it yourself.
Configure the compile
heximon.config.ts is the single source of truth for the compile — the same file vp dev/vp build, vp test (createTestApp), and every host (Vite, Nitro, Nuxt) read identically. There's no entry and no
compile script to point at — the Nuxt module loads it, compiles server/, and wires the mount itself.
heximon.config.ts option | Description |
|---|---|
plugins | The compiler plugin set to honor ([new HttpPlugin()], plus event/CQRS/etc. plugins for those tiers) |
Declare it once in heximon.config.ts at the project root; nuxt.config.ts needs no Heximon-specific
options at all beyond listing the module. The generated .heximon wiring is always written to the project root
(<rootDir>/.heximon, gitignored) under Nuxt — outside Nuxt's .nuxt build directory, which Nuxt cleans
mid-build (that would otherwise wipe the emitted nitro-entry.js before Nitro resolves it). You never configure
this, and heximon.config.ts's debug option — which elsewhere toggles whether the wiring is written to disk —
has no effect here: the Nitro/Nuxt host always materializes it, debug or not.
/api prefix lives in your contract, not the host. Because GreetingApi declares prefix: "/api"
and the controller binds to it (Controller<GreetingApi>), its routes mount under /api/* and leave the
rest of the URL space to Nuxt's pages on the same Nitro server. Keep this in mind when choosing contract
prefixes for a Nuxt app — pick one that won't collide with a Nuxt route.See also
- Nitro host — the Node/Nitro host that does the compile-and-mount work the Nuxt module delegates to, useful when you're serving an API without Nuxt pages in front of it.
- Controllers — routes, validation, cookies, and the two controller modes in depth.
- REST Client — the full
ClientTransport/raw()/ interceptor / vue-query reference this page's typed-client section builds on. - Durable Objects and WebSockets — push the same modules onto Cloudflare's stateful actors when you outgrow a single server.
- Nuxt module example — a
Vue page that calls a Heximon
/api/*route during SSR with cookies forwarded automatically, a second page reading the same routes through the typed contract client + vue-query, and an in-process test that proves both the cookie sign-in / profile / sign-out flow and the typed client's callers end-to-end.
Nitro
Run a Heximon app inside Nitro v3 with heximonNitroModule, heximonNitroVitePlugin for the dev loop, heximon.config.ts, NitroFetchClient, and one server directory that deploys to Node, Cloudflare Workers, Deno, Bun, and AWS Lambda.
Mixed-runtime split
Place one app's controllers across edge and node runtimes in a single deploy — the compiler partitions the DI graph by runtime, proves each edge tier is edge-safe before deploy, and emits separate functions routed by prefix. Vercel Functions and Netlify Functions.