The server runtime/Nitro and h3

Nitro and h3

The live request path, programmatic startup, and why srcDir decides whether your routes exist.

One idea: a file in server/api is an endpoint

BionicJS does not have an API router. You do not register a route, you do not write a manifest, and there is no api key in bionicjs.config.ts. You add a file:

server/api/health.ts
1import { defineEventHandler } from "h3";
2
3export default defineEventHandler(() => ({
4  status: "ok",
5}));

That file is the endpoint /api/health. Nothing else is needed. This chapter explains the three layers that make that sentence true, and - just as importantly - what each layer is not responsible for.

The three layers

h3, Nitro, and Vite are three different projects that solve three different problems. Confusing them is the single most common source of confusion in this part of the stack, so it is worth being precise.

LayerOwnsDoes not own
h3The handler shape. An H3Event, a return value, middleware composition.Filesystem routing, building, deployment, HTTP server sockets.
NitroThe server. Scans server/api into a router, builds and bundles it, picks a deployment preset.Your client, your bundler, your React components.
ViteThe client. Transforms and bundles app/, serves modules and HMR, proxies /api in dev.Anything that touches the server runtime.

The relationship is: Nitro is built on h3. Nitro is the framework; h3 is the HTTP handler library inside it. When you write defineEventHandler you are using h3's API, and Nitro is what turns your files into a running h3 application.

ⓘ
A common misreading: “is h3 used on both sides?” No. h3 is server-only. The browser never sees it. When people say h3 is on “both sides” of a BionicJS project, they are almost always describing two copies of the h3 package inside node_modules - which is a real bug we hit, and one worth understanding. See Two copies of h3 below.

Where h3 actually appears

h3 is a dependency that nitropack owns. Your app carries the same range, and the reason is not preference - it is that your own route files import it:

node_modules/nitropack/package.json
1{
2  "dependencies": {
3    "h3": "^1.15.11"
4  }
5}
server/api/health.ts
1import { defineEventHandler } from "h3";
2
3export default defineEventHandler(() => ({ status: "ok" }));

That import resolves from your project, and pnpm gives a project a strict node_modules by default. A transitive dependency of a dependency is not resolvable from your source - there is no hoist or publicHoistPattern configured anywhere in the workspace. So h3 has to be a direct dependency of the app, pinned to the same range Nitro resolves, and the two dedupe to a single copy.

Two copies of h3

For a long time the base template pinned its own h3@2.0.1-rc.31 while nitropack@2.13 depended on h3@^1.15.11. A scaffolded app therefore installed two majors of the same package side by side:

terminal
1$ find node_modules -path '*h3/package.json' | while read f; do
2    echo "$(node -p "require('./$f').version")  $f"
3  done
41.15.11    node_modules/.pnpm/h3@1.15.11/node_modules/h3/package.json
52.0.1-rc.31 node_modules/.pnpm/h3@2.0.1-rc.31_.../node_modules/h3/package.json

This fails in a way that is genuinely hard to debug, because the handlers are not errors - they are simply invisible to the server. The two majors do not share a module instance, and they do not even build the same function:

terminal
1app   h3 v2  defineEventHandler -> (anonymous)  arity 0
2nitro h3 v1  defineEventHandler -> defineEventHandler  arity 1
3same module instance: false

Your server/api/*.ts handlers came from v2. Nitro dispatched through v1. Every route 404'd, with no warning from either package.

ⓘ
The fix was to align, not to delete. Dropping the pin is not an option: your own server/api/*.ts imports h3, so the range has to match the runtime it is written against. The rule is narrower than “no runtime deps” - never pin a different major of a runtime dependency than the runtime it is written against. Packages only the framework imports (nitropack, vite, @vitejs/plugin-react) we removed from the template outright; @bionicjs/dev owns those.

How BionicJS starts Nitro

You never write a nitro.config.ts, and you never run nitro dev. bionicjs dev constructs and starts Nitro in-process, from @bionicjs/dev. This is the whole startup sequence:

runDevServer
1// 1. Generate the framework-owned modules from bionicjs.config.ts
2await generateAll(cwd);
3
4// 2. Create Nitro. srcDir is what makes server/api/ visible.
5const nitro = await createNitro({
6  rootDir: cwd,
7  srcDir: path.join(cwd, "server"),
8  dev: true,
9  compatibilityDate: "2026-09-14",
10  publicAssets: existsSync(publicDir) ? [{ dir: publicDir }] : [],
11  alias: existsSync(generatedServer)
12    ? { "@bionicjs/core/server": generatedServer }
13    : {},
14});
15
16// 3. Listen, prepare types, then run the rebuild watcher
17const nitroDevServer = createDevServer(nitro);
18await nitroDevServer.listen(3001);
19await prepare(nitro);
20build(nitro).catch(/* logged */);
21
22// 4. Start Vite for the client, proxying /api to Nitro
23const vite = await createViteServer({ /* ... */ });
24await vite.listen();   // :3000

Two details in that block are load-bearing, and both were bugs before they were documented.

srcDir is load-bearing

Nitro scans <srcDir>/api, <srcDir>/routes, <srcDir>/middleware and <srcDir>/plugins. It does not scan <srcDir>/server/api.

Left at its default, srcDir equals rootDir, which is the project root - so Nitro looked for ./api and registered zero routes. The server/ directory was never opened.

ⓘ
srcDir is correct for the version we run, but it is not where Nitro is going. On nitropack@2.x, serverDir means the build output directory (output.serverDir), and srcDir is the documented root for api/, routes/, public/ and friends. Nitro v3 flips this: srcDir is deprecated in favour of a source-level serverDir, and filesystem routing is off until you enable it. So this line is expected to change with the upgrade.

Setting srcDir moves the scan root, which has knock-on effects. Anything Nitro resolves relative to srcDir now looks inside server/ - most visibly public/, which is a sibling of server/ and therefore has to be passed explicitly as publicAssets.

That side effect is avoidable, and it is the reason to prefer scanDirs when we move to Nitro v3. scanDirs adds a route-scanning root without relocating public/, utils/ or assets/:

Nitro v3
1export default defineConfig({
2  serverDir: "server",   // replaces srcDir
3  // scanDirs: ["./server"] as an alternative when you only want extra scan roots
4});
ⓘ
This is why Nuxt does not ask you to think about any of it. Nuxt owns the Nitro configuration outright - you can pass a nitro key in nuxt.config.ts for advanced use, but server/api/ just works. That is the model BionicJS is copying.

The request lifecycle

In development, a browser request to http://localhost:3000/api/health takes this path:

request path
1browser  ──▶  Vite  :3000
2               │  /api/* matches the proxy rule
3               ▼
4            Nitro  :3001
5               1. request hook
6               2. route rules (headers, redirects)
7               3. global middleware
8               4. route matching ──▶ server/api/health.ts  (h3 handler)
9               5. server entry        (catch-all /**, if present)
10               6. renderer            (the HTML shell, for non-API requests)
11
12response ◀── back through the proxy ◀── browser

Anything that is not /api and asks for text/html is served by Vite's own middleware, which synthesises the HTML shell, runs it through transformIndexHtml, and returns it. The shell loads /@id/@bionicjs/core/entry-client, which is where React takes over.

  • 3000 is the app origin. The browser only ever talks to Vite.
  • 3001 is internal. It is where Nitro actually listens, and it is reachable only through the proxy.
  • strictPort is on, so a port conflict fails loudly instead of silently moving the app somewhere you did not ask for.

The generated server interface

bionicjs.config.ts is turned into a real TypeScript module at .bionicjs/server.ts, exporting auth, db, ai and jobs. The point of generating a module rather than injecting globals is that both compilers can see it, so the types work in an editor.

Because it is a real file at a path Vite and Nitro both understand, it needs an alias in each. Vite gets it through resolve.alias; Nitro gets it through the alias option we pass to createNitro. Miss the second one and server routes silently import an empty stub instead of the real interface.

server/api/me.ts
1import { auth } from "@bionicjs/core/server";
2
3export default defineEventHandler(async (event) => {
4  return auth.getSession(event);
5});
ⓘ
The alias only exists at dev time. In production the module has to resolve for real. That parity is still an open item - see How BionicJS runs: dev, build, and production.