Heximon Logo
Deploy

Nuxt

Run a Heximon API inside a Nuxt 5 app on one Nitro server, with SSR cookie forwarding, a typed Contract client with TanStack Vue Query bindings, heximon.config.ts, HttpPlugin, and a server/ controller.

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

@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.

nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: "2025-07-04",
  srcDir: "app",
  modules: ["@heximon/nuxt"],
});
heximon.config.ts
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.

shared/greeting/contract.ts
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">.

server/greeting/greeting.controller.ts
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.

server/greeting/greeting.module.ts
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.

server/app.module.ts
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.

app/pages/index.vue
<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

@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:

app/pages/typed.vue
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.

TanStack Vue Query's 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 optionDescription
pluginsThe 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.

The /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.
Copyright © 2026