Expose a service through routes
Keep HTTP separate from domain behavior and map explicit URL methods to public service functions.
Use a service to expose application operations and a route to give an operation an HTTP method and URL. A route says which operation is
reachable at a particular URL and HTTP method. Adding a function to service.ts does not create a URL;
adding a domain does not create a server or a route prefix.
Keep the route files under src/routes/, outside the domains. This page covers the service-to-HTTP
boundary; HTTP Routes getting started introduces request parsing, middleware, responses,
and client contracts.
Assign each responsibility once
| Concern | Owner |
|---|---|
| Business input, output, errors, and behavior | The domain function. |
| Which domain functions callers may use | The domain’s service.ts. |
| URL path and HTTP method | The route file path and its named exports. |
| Headers, cookies, query/body mapping, response status | The route’s transport configuration. |
| HTTP middleware and request authentication | The Routes layer. |
| Rules that must also apply to jobs, tools, and direct calls | The function, not only HTTP middleware. |
Routes import @app/services/orders/service.js, never Orders’ private function files. Domain functions do not
import routes. That direction lets you add another transport without pulling HTTP concerns into the
business operation.
Map methods to public functions
With an existing Orders service exposing searchOrders and createOrder, scaffold a new route:
bun run relkit add route /orders --mode service-route --service orders \
--map GET=searchOrders --map POST=createOrder
This creates src/routes/orders/route.ts and maps both methods. If that file already exists,
edit it instead. The command uses inferred mapping and HTTP 200; add the explicit request
mapping and 201 status in source as shown below. See the
route command reference for all flags.
Use defineServiceRoutes when the methods in a route file belong to one service:
/**
* 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,
},
});
The file supplies /orders. The exported GET and POST names supply the methods. GET selects the
public searchOrders function by member name. POST selects createOrder, explicitly maps headers
and body fields into its domain input, and sets the successful response status to 201.
Use the service routes 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.
Use a string member name when inferred request/response mapping fits. Use an object with member when
you need explicit mapping, status, limits, or other function-route options. Explicit request mappings
replace inference rather than partially extending it. See request mapping.
Keep the exported destructuring form shown above. defineServiceRoutes does not support renamed
method bindings, rest bindings, a default export, or methods named after business operations. Supported
methods are GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.
Choose the URL independently of the domain
| Route file | URL | What it decides |
|---|---|---|
src/routes/orders/route.ts |
/orders |
Collection operations. |
src/routes/orders/[orderId]/route.ts |
/orders/:orderId |
Operations on a particular order. |
src/routes/uploads/route.ts |
/uploads |
A URL that calls the Assets service without using its domain name. |
The service does not own these URL prefixes. Several route files can use the same service, and a route module can import multiple public services if necessary. Keep reusable cross-domain business orchestration in a function instead of spreading it across HTTP handlers.
Follow file-system routes for dynamic segments, catch-all paths, matching precedence,
and route identities. Do not add path or method to a function-backed defineRoute descriptor.
When to use defineRoute
For an individual mapping, use defineRoute with a public function as its target:
/** Maps the HTTP endpoint to its owning service's public member. */
import { defineRoute } from "@relkit/app/routes";
import hello from "@app/services/hello/service.js";
export const GET = defineRoute({
target: hello.hello,
});
Both factories produce route descriptors for the same routing pipeline. Neither can target an event contract, an event-only subscriber, a tool, or an agent directly. To expose agent behavior, route to the ordinary function that calls it.
defineRoute({ handler: ... }) also supports raw HTTP integrations. Keep business operations on the
function-backed path. Better Auth is a specialized raw-handler integration mounted on an ALL
catch-all route; ALL is not a method accepted by defineServiceRoutes.
Use from the frontend
After frontend setup and contract pull, render the Hello route
through its generated GET /hello selector. name and data.message are inferred from the
backend function’s schemas:
"use client";
/** Renders the minimal backend's greeting through the pulled registry and a same-origin client provider. */
import { RelkitClientProvider, useRelkitClient, useRoute } from "@relkit/client/react";
/** Displays the typed Hello result after the provider resolves backend identity.
* @returns Loading, connection failure, request failure, or the successful greeting. */
export function Greeting() {
const { status } = useRelkitClient();
const greeting = useRoute("GET /hello", { input: { name: "RELKIT" } });
if (status === "application-updated") {
return <p role="alert">The API changed. Pull its contract and restart the frontend.</p>;
}
if (status === "error") return <p role="alert">Cannot connect to the API.</p>;
if (greeting.isError) return <p role="alert">{greeting.error.message}</p>;
if (greeting.isPending) return <p role="status">Loading greeting…</p>;
return <h1>{greeting.data.message}</h1>;
}
/** Supplies the shared query cache and identity handshake to the frontend tree.
* @returns The greeting inside a same-origin RELKIT provider. */
export default function App() {
return (
<RelkitClientProvider>
<Greeting />
</RelkitClientProvider>
);
}
Keep .invoke() and service imports on the backend. A function or service needs a client-exposed
route before the browser can call it. The provider handles identity and query state; the
Frontend guide also shows where this component belongs in Next.js.
Reference: useRoute,
RelkitClientProvider.
defineServiceRoutes produces the same client contracts per exported method: GET /orders uses
useRoute, while POST /orders uses
useRouteMutation. The input remains the target function’s input;
explicit HTTP header or body mappings do not construct RPC input. Only exposing a service member
does not make it a browser operation: it needs a client-exposed route.
Continue with the Routes topics
Choose the topic that matches the HTTP work you need to do:
Your first HTTP route: add a validated endpoint and integration test.
Define a route: choose defineRoute or defineServiceRoutes.
File-system routes: choose paths and methods.
Middleware and rate limits: apply HTTP policy.
Responses: map results and declared errors into HTTP responses.
Raw handlers: mount adapters that own their HTTP protocol.
OpenAPI and generated clients: use the checked contracts outside the server, with REST and RPC behavior distinguished.
These transport guides build on the same domain organization; they do not introduce a second place for business handlers.
Verify routing separately from behavior
From your application’s root, run bun run check after adding a method. Expect the route to appear in
the checked graph and OpenAPI output. Test the function directly for its domain behavior, then use an
HTTP integration test for mapping, authentication, and status codes.
For a running app containing the Hello route above:
curl -i http://localhost:3000/hello\?name=RELKIT
Expect HTTP 200 and { "message": "Hello, RELKIT!" }. Use your configured backend port if it differs.
The first-service example includes the source and test steps.