Server

Routes

Learn how to define and register routes in mion APIs.

A route is a function your client calls remotely. Its first parameter is always the Call Context. The rest come from the client, validated before the route runs.

Your API is a plain object of routes and middleware, with no URLs to write. Each route gets a URL underneath, which the client handles for you.

Creating the Router

Create the router once per app with createMionRouter and pass it every router option (base path, parser, context data factory and the rest). Every helper it returns (route, query, mutation, middleware, headersMiddleware, rawMiddleware) gets those options by type.

const mion = createMionRouter({
  contextDataFactory: getSharedData,
  basePath: 'api/v1',
});
Create the router and call initRoutes in your routes file, then import that file from the server entry. Destructuring the helpers is fine: const {route, middleware} = createMionRouter({...}).
Call createMionRouter and initRoutes once each, with all your routes in one initRoutes call. A second call to either throws.

Defining a Route

Declare a route with mion.route: the handler is the first argument and the RouteOptions the second.

import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import type {SomeData as Data} from './full-example.app.ts';
import {myDbService as db} from './full-example.app.ts';

const mion = createMionRouter();

export const routes = {
  sayHello: mion.route((ctx, name1: string, name2: string): string => {
    return `Hello ${name1} and ${name2}.`;
  }),
  getSomeData: mion.route(
    async (ctx, id: string): Promise<Data | RpcError<'data-not-found'>> => {
      const data = await db.getData(id);
      return (
        data ||
        new RpcError({publicMessage: 'Data not found', type: 'data-not-found'})
      );
    }
  ),
} satisfies Routes;
Never give routes or middleware a type. Use the satisfies operator instead, so the router stays fully typed.

Query and Mutation Handlers

The query and mutation helpers declare what a route does, so the client can choose how to send it. A query sends small payloads in the URL as a GET, a mutation always in the body. A mion.route() declares no intent, sends its data in the body and has no cache semantics.

import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

type User = {name: string; surname: string};

const routes = {
  // mion.query() for read-only operations
  // client automatically uses GET with ?data=base64url for small payloads
  getUser: mion.query((ctx, id: number): User | RpcError<'user-not-found'> => {
    return {name: 'John', surname: 'Doe'};
  }),

  // mion.mutation() for operations that modify data
  // client always uses POST with body
  createUser: mion.mutation((ctx, user: User): User => {
    return user;
  }),

  // mion.route() declares no intent (client uses POST)
  sayHello: mion.route((ctx, name: string): string => {
    return `Hello ${name}`;
  }),
} satisfies Routes;
Prefer mion.query() and mion.mutation(): your code reads better, and it leaves room for future optimizations like caching layers.

Undeclared Properties

By default, a param property your types do not declare is dropped while the request is decoded, not at validation. The clone and compact strategies rebuild the params from your declared type.

To reject the request instead, set parser: {params: 'mutateStrict'}. See Validation.

import {createMionRouter, Routes} from '@mionjs/router';

interface User {
  name: string;
  email: string;
  age: number;
}

// every route on this router rejects params carrying a property the type does not declare
const mion = createMionRouter({parser: {params: 'mutateStrict'}} as const);

const routes = {
  // inherits mutateStrict from the router
  createUser: mion.route((ctx, user: User): User => user),
  // this route drops the extra property instead of refusing the request
  updateUser: mion.route((ctx, user: Partial<User>): Partial<User> => user, {
    parser: {params: 'clone'},
  }),
} satisfies Routes;

mion.initRoutes(routes);

Registering Routes

Pass the routes object to mion.initRoutes, once per app. It initializes the router with the options given to createMionRouter, registers the routes and returns the API type the client uses.

import {HeadersSubset} from '@mionjs/core';
import {createMionRouter, PublicApi, Routes} from '@mionjs/router';

const mion = createMionRouter();

const authRoutes = {
  logIn: mion.route(
    (ctx, email: string, password: string): string => 'loggedIn'
  ),
  logOut: mion.route((): string => 'loggedOut'),
} satisfies Routes;

const routes = {
  trace: mion.headersMiddleware(
    (ctx, headers: HeadersSubset<'X-Trace-Id'>): void => undefined
  ),
  sayHello: mion.route((ctx, name: string): string => 'hello ' + name),
} satisfies Routes;

// routes split across files are spread into the one initRoutes call
export const myApi = mion.initRoutes({...routes, ...authRoutes});

export type MyApi = typeof myApi;
// a sub-api can be typed on its own from its routes object
export type AuthApi = PublicApi<typeof authRoutes>;

Naming Routes

Name routes with valid JS variable names, not quoted keys.

import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const sayHello = mion.route((ctx, name: string): string => {
  return `Hello ${name}.`;
});

const routes = {
  'say-Hello': sayHello, // path = /say-Hello  !! NOT Recommended
  'say Hello': sayHello, // path = /say%20Hello  !! ROUTE WONT BE FOUND
} satisfies Routes;
Route lookup does not decode the URL, so a name with characters that need URL encoding is never found. initRoutes refuses the names __proto__, prototype and constructor.

Sanitizing Params

A route can apply the transforms its param types declare (trim, lowercase, strip separators) after decoding and before validation, on the server and in the client. Turn it on with sanitizeParams, globally in createMionRouter or per route. See Validation.

import {createMionRouter, Routes} from '@mionjs/router';
import type * as TF from '@mionjs/run-types/formats';

// The type says what a clean value looks like: trimmed and lowercased.
type Email = TF.Transform<TF.Email, {trim: true; lowercase: true}>;

// Turn sanitizing on for every route, or per route below. It is off by default.
const mion = createMionRouter({sanitizeParams: true});

const routes = {
  // ' [email protected] ' arrives, the handler gets '[email protected]'
  login: mion.route(
    (ctx, email: Email, password: string): boolean =>
      email.length > 0 && password.length > 0
  ),
  // this route opts out: the handler gets the value exactly as sent
  echo: mion.route((ctx, email: Email): string => email, {
    sanitizeParams: false,
  }),
} satisfies Routes;

mion.initRoutes(routes);

Request Size

mion works out the largest valid request for each route from the parameter types of the route and its middleware. A bigger request gets a 413 error, as early as the platform allows. You write no size limits by hand.

import {createMionRouter, Routes} from '@mionjs/router';
import * as TF from '@mionjs/run-types/formats';

interface Item {
  id: TF.String<{maxLength: 36}>;
  qty: number;
}

// the one router-wide knob: the slack applied to a limit derived from the types
const mion = createMionRouter({maxBodySizeFactor: 2});

const routes = {
  // every param has a maximum, so the limit is derived from the types
  addItems: mion.route(
    (
      ctx,
      orderId: TF.String<{maxLength: 36}>,
      items: TF.FormattedArray<Item[], {maxItems: 50}>
    ): number => items.length
  ),

  // a plain string has no maximum, so this route takes the adapter's number
  search: mion.route((ctx, text: string): number => text.length),

  // the route option wins over both
  upload: mion.route((ctx, payload: string): number => payload.length, {
    maxBodySize: 512_000,
  }),
} satisfies Routes;

mion.initRoutes(routes);

The first limit that applies wins:

LimitUsed when
maxBodySize on the routeYou set it on the route options.
From the typesEvery parameter has a maximum, like a string with maxLength or an array, Set or Map with maxItems. The limit is the largest valid JSON body, times maxBodySizeFactor (a router option, 2 by default).
maxBodySize on the platform adapterA parameter has no maximum, like a plain string, string[], Map, record or any. 128 KB by default.
Give every parameter a maximum, so each route gets its own exact limit instead of the platform default.
Responses are not limited. To turn off limits from the types, set derivedPayloadLimits: false in the runTypes block of the Vite plugin or withMion.

Platform Ceilings

Some platforms refuse a request past their own ceiling before mion runs, and no route limit goes above it. Each platform page lists its ceiling, and the adapter's maxBodySizeCap option sets it.

import {createCloudflareHandler} from '@mionjs/platform-cloudflare';

// a bigger plan allows more than the default ceiling
createCloudflareHandler({maxBodySizeCap: 200_000_000});

Keeping the Raw Body

Once the body is parsed mion drops the raw text, so ctx.request.rawBody reads empty in your handlers. Set releaseRawBody: false in createMionRouter to keep it, for an access log that records exactly what arrived.

Awaiting Every Step

mion awaits every middleware and route, even synchronous ones, so other requests can run between steps. Set alwaysAwait: false to skip the await on synchronous handlers: faster, but the slowest requests can get slower under load. A route whose middlewares and handler are all synchronous then answers with no promise (on Node.js and uWebSockets.js always, on Bun when the request has no body).

const mion = createMionRouter({alwaysAwait: false});
Copyright © 2026