Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Define a route

Connect named HTTP methods to public service functions with defineRoute or defineServiceRoutes.

Define a route to connect an HTTP method and path to a public service operation. Import the target through its domain’s service.ts; never reach into another domain’s private function directory from a route. The file-system conventions determine the URL and allowed method exports.

Scaffold a route

Run these commands from an installed application’s root. Choose the mode that matches the endpoint you are creating; then edit the generated file using the API examples below.

To route through existing public functions, use service-route mode. With details and updateDetails public callable members in the users service:

bun run relkit add route /users/:id/details \
  --mode service-route --service users \
  --map GET=details --map PATCH=updateDetails

This creates src/routes/users/[id]/details/route.ts with defineServiceRoutes. The mapped names are public service member names, not private filenames or event IDs. Headless service-route commands need at least one --map; supported methods are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. ALL is auth-only.

From an installed application’s root, create a standalone raw handler:

bun run relkit add route /status --mode route

This creates src/routes/status/route.ts with a raw GET returning { ok: true }. It needs no service or function. It appears in OpenAPI/Scalar with a generic response contract, not an inferred function response schema. --map is not accepted in this mode.

For a complete function-backed endpoint, follow Add an Orders endpoint. The CLI creates the method mapping; add request mapping, response status, and policy in source.

URL passed to the command Generated file
/ src/routes/route.ts
/users/:id/details src/routes/users/[id]/details/route.ts
/files/*parts src/routes/files/[...parts]/route.ts
/files/*parts? src/routes/files/[[...parts]]/route.ts

Write route paths without quotes. Escape literal shell wildcards, for example /files/\*parts or /files/\*parts\?, so the shell passes them to the CLI unchanged. Existing target files are never overwritten; edit an existing route to add another method. Run bun run check, then verify the endpoint and its entry in /_relkit/v1/openapi.json while dev is running. The Scalar UI is available when explicitly selected.

For every route scaffolding flag, see the route command reference.

Expose one public function

Use defineRoute for one method-to-function mapping:

/** Maps the HTTP endpoint to its owning service's public member. */
import { defineRoute } from "@relkit/app/routes";
import echo from "@app/services/echo/service.js";

export const POST = defineRoute({
  target: echo.echo,
});

This is POST /echo. Omit request and responses when the input schema and output describe ordinary JSON correctly. The descriptor selects behavior; it does not call echo.echo.invoke() at module load.

A target must be an ordinary callable function exposed by a service. An event contract, event-only subscriber, tool, or agent is not a route target. To expose agent behavior, use a public function that calls the agent.

Use from the frontend

After frontend installation, contract pull, and provider setup, call this POST /echo operation with its domain input. The route produces both the React selector POST /echo and the TypeScript operation route.post.echo.

Render this component under RelkitClientProvider. Clicking the button sends { message } and displays the inferred { message: string } result:

"use client";

/** Calls the documented POST /echo function route after an explicit user action. */
import { useRelkitClient, useRouteMutation } from "@relkit/client/react";

/** Sends domain input and renders the validated output; the hook owns mutation state.
 * @returns An Echo button with pending, failure, and success feedback. */
export function EchoButton() {
  const { status } = useRelkitClient();
  const echo = useRouteMutation("POST /echo");

  return (
    <section>
      <button
        disabled={status !== "ready" || echo.isPending}
        onClick={() => echo.mutate({ message: "Hello from the frontend" })}
      >
        Send message
      </button>
      {echo.isPending && <p role="status">Sending…</p>}
      {echo.isError && <p role="alert">{String(echo.error)}</p>}
      {echo.isSuccess && <p>{echo.data.message}</p>}
    </section>
  );
}

Without React, load the browser identity before the mutation and call the generated operation:

/** Calls the Echo contract from a TypeScript browser frontend without React or backend source imports. */
import { createClient } from "@relkit/client";

/** Sends one message over RPC using types from the caller's pulled contract.
 * @param baseUrl - Public backend origin, or the frontend origin when proxying RPC.
 * @param message - Nonempty message accepted by the Echo function.
 * @returns The validated message output; transport and application failures reject. */
export async function echoMessage(baseUrl: string, message: string) {
  const response = await fetch(new URL("/_relkit/v1/client/identity", baseUrl), {
    credentials: "include",
    cache: "no-store",
  });
  if (!response.ok) throw new Error(`Identity request failed (${response.status})`);
  const identity = await response.json();
  if (
    !identity ||
    typeof identity.identityScope !== "string" ||
    typeof identity.sessionEpoch !== "string"
  ) {
    throw new Error("The backend returned an invalid client identity");
  }
  const client = createClient({
    baseUrl,
    headers: {
      "x-relkit-identity-scope": identity.identityScope,
      "x-relkit-session-epoch": identity.sessionEpoch,
    },
  });
  return client["route.post.echo"]({ message });
}

The React provider performs this identity step for hooks.

These calls use RPC over /rpc, with the same authorization and policy. Pass domain input rather than assembling the route’s REST mapping. Raw handlers and routes with client: false do not generate these operations. Never import the backend descriptor into the browser.

Reference: useRouteMutation, createClient. Continue with mutations.

Group methods that use one service

Use defineServiceRoutes to select public function members by name:

/**
 * Maps order HTTP operations to public service members with the existing request
 * and rate-limit policies. The rate-limit reference addresses the declared cache
 * by its stable identity without importing a private service implementation.
 */
import { defineServiceRoutes, http } from "@relkit/app/routes";
import { z } from "@relkit/app/schema";
import orders from "../../services/orders/service.js";

export const { GET, POST } = defineServiceRoutes(orders, {
  GET: "searchOrders",
  POST: {
    member: "createOrder",
    request: http.input({
      orderId: http.header("idempotency-key"),
      customerEmail: http.header("x-customer-email"),
      sku: http.body("sku"),
      quantity: http.body("quantity"),
    }),
    rateLimit: {
      limit: 20,
      windowMs: 60_000,
      key: http.header("x-customer-email"),
      store: {
        ref: { kind: "cache", id: "orders.rate-limits" },
        key: z.string(),
        value: z.number().int().nonnegative(),
      },
    },
    successStatus: 201,
  },
});

GET: "searchOrders" uses inference. The POST object selects createOrder and adds HTTP-specific mapping and a 201 success status. Both forms produce the same kind of checked function route.

Use both methods from the frontend

After frontend setup and pulling this backend’s contract, render this component under RelkitClientProvider. Each exported service-route method produces its own typed operation: GET /orders reads the search count, while POST /orders creates an order.

"use client";

/** Calls both commerce service-route methods using their generated domain inputs inside the frontend provider. */
import { useRelkitClient, useRoute, useRouteMutation } from "@relkit/client/react";

/** Reads the Orders search count and submits one explicit creation without replaying a failed write.
 * @returns Typed query results, creation feedback, and an action available after identity is ready. */
export function Orders() {
  const { status } = useRelkitClient();
  const orders = useRoute("GET /orders", { input: { status: "pending" } });
  const create = useRouteMutation("POST /orders");

  return (
    <section>
      {orders.isPending && <p role="status">Loading orders…</p>}
      {orders.data && <p>{orders.data.count} orders</p>}
      {orders.isError && <p role="alert">{orders.error.message}</p>}
      <button
        disabled={status !== "ready" || !create.isIdle}
        onClick={() =>
          create.mutate({
            orderId: crypto.randomUUID(),
            customerEmail: "reader@example.com",
            sku: "book",
            quantity: 1,
          })
        }
      >
        Create an order
      </button>
      {create.isPending && <p role="status">Creating…</p>}
      {create.isSuccess && <p>Created: {create.data.orderId}</p>}
      {create.isError && (
        <p role="alert">{create.error.message}. Check the outcome before retrying.</p>
      )}
    </section>
  );
}

status, count, and the creation input come from the selected functions’ schemas. RPC receives orderId and customerEmail as input fields even though REST maps them from headers. A service’s public member alone is not a browser endpoint: the client-exposed route supplies that boundary.

Reference: useRoute, useRouteMutation. See request mapping for the REST side and mutations for submission and recovery behavior.

Keep the exported destructuring form. Supported keys are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS; do not rename the bindings, add a rest binding, or use a default export. ALL is reserved for raw handlers, not defineServiceRoutes.

Several route files can use the same service. Individual defineRoute exports in one file can target different public services. Put reusable cross-domain orchestration in a domain function instead of creating business logic inside the route module.

Add only the transport options you need

Option Use it when…
request Inference cannot express headers, cookies, names, or nesting. It replaces all inferred input mapping. See requests.
accept The route consumes multipart/form-data instead of JSON. See uploads.
successStatus One successful JSON operation needs a different 2xx status, such as 201.
responses You need a complete explicit response contract. It replaces inference; see responses.
maxBodyBytes Parsing this route needs a specific positive byte ceiling.
timeoutMs The function invocation needs a route-specific positive timeout.
rateLimit Requests need a keyed limit before mapping and invocation. See rate limits.
id, title, description, tags You need stable operation identity or human-facing metadata.

Do not add path or method: the compiler derives both from source. There is no service-level URL prefix or middleware property. The auth option is specific to a recognized Better Auth raw handler, not a generic authorization flag for a function route.

Check the new operation

From the application root:

bun run check
bun run typecheck

Resolve diagnostics before starting the server. Check the generated route method, path, target, input mapping, and responses. Then test the endpoint itself: a direct function test does not exercise HTTP parsing or policy. The first-route walkthrough includes both the source and an in-process HTTP test.

Reference and source