Skip to content

Serving & Clients

With the API defined and the handlers implemented, two things are derived from the same Api value: a server that mounts the routes and a typed client whose methods mirror the endpoints. This page wires both, then enumerates the full surface of HttpApiBuilder, HttpApiClient, and HttpApiTest.

HttpApiBuilder.layer(Api) turns the API plus its handler groups into routes. Provide each group’s handler Layer, then serve the result with a platform HTTP server. The example also mounts interactive docs and exposes the OpenAPI JSON.

server/main.ts
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { Api } from "../api/Api.ts"
import { AuthorizationLayer } from "./Authorization.ts"
import { SystemApiHandlers } from "./System/http.ts"
import { Users } from "./Users.ts"
import { UsersApiHandlers } from "./Users/http.ts"
// Build the API routes and serve the OpenAPI document at /openapi.json
const ApiRoutes = HttpApiBuilder.layer(Api, {
openapiPath: "/openapi.json"
}).pipe(
// Provide every group's handler Layer (and their dependencies)
Layer.provide([
UsersApiHandlers.pipe(Layer.provide([Users.layer, AuthorizationLayer])),
SystemApiHandlers
])
)
// Serve interactive Scalar docs at /docs
const DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })
// Combine all the route layers
const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)
// Mount the routes on a Node HTTP server listening on port 3000
const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)
// Launch and run. Layer.launch keeps the server alive until interrupted.
Layer.launch(HttpServerLayer).pipe(NodeRuntime.runMain)

layer registers every group’s routes onto the ambient HttpRouter. It returns a Layer that fails loudly (via Effect.die) if a group’s handler layer was not provided, naming the missing HttpApiBuilder.group(api, "<id>", ...).

The only option is openapiPath — a `/${string}` path on which to serve the generated OpenAPI 3.1 JSON document. Omit it to skip the OpenAPI route.

import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
// With the OpenAPI document mounted at GET /openapi.json
HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })
// Without — just the API routes, no spec endpoint
HttpApiBuilder.layer(Api)

The layer requires the platform services a router needs to encode responses: Etag.Generator, FileSystem, HttpPlatform, Path, the HttpRouter, and the ToService of every group (i.e. the handler layers).

For environments that speak the Fetch API (edge functions, workers), produce a (request: Request) => Promise<Response> handler instead of binding a port. HttpRouter.toWebHandler builds the handler from a route Layer; HttpServer.layerServices supplies the platform services a router needs without a running server:

import { Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
// `handler` is (request: Request) => Promise<Response>; `dispose` tears down
// the scoped runtime the handler was built with.
export const { handler, dispose } = HttpRouter.toWebHandler(
AllRoutes.pipe(Layer.provide(HttpServer.layerServices))
)

HttpApiClient.make(Api) builds a client whose shape exactly follows the API: non-topLevel groups become nested namespaces, endpoints become methods, and topLevel groups attach their methods directly to the root. Inputs and outputs are typed and validated against the same schemas the server uses.

client/ApiClient.ts
import { Context, flow, Layer, Schedule } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
import { AuthorizationClient } from "./AuthorizationClient.ts"
export class ApiClient extends Context.Service<
ApiClient,
// ForApi derives the full client interface from the API definition
HttpApiClient.ForApi<typeof Api>
>()("acme/ApiClient") {
static readonly layer = Layer.effect(
ApiClient,
HttpApiClient.make(Api, {
// transformClient adjusts the underlying HttpClient: set the base URL,
// add retries, attach default headers, etc.
transformClient: (client) =>
client.pipe(
HttpClient.mapRequest(flow(
HttpClientRequest.prependUrl("http://localhost:3000")
)),
HttpClient.retryTransient({
schedule: Schedule.exponential(100),
times: 3
})
)
})
).pipe(
// Provide the client implementation of any required client middleware
Layer.provide(AuthorizationClient),
// Provide an HttpClient backend. FetchHttpClient works in the browser and
// edge runtimes; NodeHttpClient / BunHttpClient are also available.
Layer.provide(FetchHttpClient.layer)
)
}

Calling the API is now just calling methods. The compiler enforces argument shapes and infers the result and error types from the endpoint definitions:

import { Effect } from "effect"
import { UserId } from "../domain/User.ts"
import { ApiClient } from "./ApiClient.ts"
export const program = Effect.gen(function*() {
const client = yield* ApiClient
// topLevel "system" group -> method on the root
yield* client.health()
// "users" group -> client.users.<endpoint>. Each method takes one object
// whose keys (params / query / payload / headers) mirror the endpoint inputs.
// `id` decodes to the branded UserId, so build it with UserId.make.
const user = yield* client.users.getById({ params: { id: UserId.make(1) } })
const created = yield* client.users.create({
payload: { name: "Ada", email: "ada@acme.dev" }
})
const all = yield* client.users.list({ query: { search: "ada" } })
return { user, created, all }
}).pipe(Effect.provide(ApiClient.layer))

Each generated method takes a single request object whose keys mirror the endpoint’s params, query, payload, and headers schemas. An endpoint with no inputs accepts void (client.health()), so the only optional key is always responseMode (below). Encoding follows fixed rules:

  • Path params are encoded with the endpoint’s params schema and substituted into the path template (/users/:id).
  • Payloads on body methods (POST, PUT, PATCH) are encoded to a request body. On bodyless methods (GET, DELETE) the payload is encoded into URL parameters instead.
  • Multipart payloads must be supplied as a FormData instance — the type of the payload key narrows to FormData for multipart endpoints.
  • Query values and headers are encoded with their respective schemas and appended/set on the request.

A successful response is decoded with the matching success schema for its status code; a declared error response decodes into the endpoint’s error type. The effect’s error channel is the union of: the endpoint’s declared _Error type, any middleware errors, HttpClientError.HttpClientError, and Schema.SchemaError when decoding fails. An unmatched status fails as an HttpClientError carrying a DecodeError.

Every generated method accepts an optional responseMode on its request object. The mode is the ClientResponseMode type from HttpApiEndpoint:

type ClientResponseMode = "decoded-only" | "decoded-and-response" | "response-only"

It controls what the call returns and which parts of the pipeline run:

  • "decoded-only" (the default) — returns the decoded success value.
  • "decoded-and-response" — returns a tuple [value, response], pairing the decoded value with the raw HttpClientResponse (handy for reading status codes or response headers alongside the body).
  • "response-only" — skips success and error decoding and returns the raw HttpClientResponse. Because nothing is decoded, the endpoint error type and SchemaError drop out of the error channel — use this for custom response handling.
import { Effect } from "effect"
import { UserId } from "../domain/User.ts"
import { ApiClient } from "./ApiClient.ts"
export const modes = Effect.gen(function*() {
const client = yield* ApiClient
const params = { id: UserId.make(1) }
// Default: the decoded User
const user = yield* client.users.getById({ params })
// => User
// Decoded value paired with the raw response — read headers / status alongside it
const [user2, response] = yield* client.users.getById({
params,
responseMode: "decoded-and-response"
})
// => [User, HttpClientResponse]
response.status // => 200
// Raw response only — no decoding, narrower error channel
const raw = yield* client.users.getById({ params, responseMode: "response-only" })
// => HttpClientResponse
const body = yield* raw.text
}).pipe(Effect.provide(ApiClient.layer))

HttpApiTest builds an in-memory client that drives your real handlers through the same encode/decode/routing pipeline as a live server — no port, no sockets. Provide the handler layers for the groups under test, then call the client just like the real one.

test/Users.test.ts
import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import { HttpApiTest } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
import { UsersApiHandlers } from "../server/Users/http.ts"
describe("Users API", () => {
it.effect("create + getById round-trip", () =>
Effect.gen(function*() {
// Build the in-memory client for the "users" group. Unselected groups
// are still present on the client but die if called.
const client = yield* HttpApiTest.groups(Api, ["users"])
const created = yield* client.users.create({
payload: { name: "Ada", email: "ada@acme.dev" }
})
assert.strictEqual(created.name, "Ada")
const fetched = yield* client.users.getById({ params: { id: created.id } })
assert.deepStrictEqual(fetched, created)
}).pipe(
// Supply the real handler layer for the selected group
Effect.provide(UsersApiHandlers)
))
})

HttpApiTest.groups is itself an Effect, so it must be yield*-ed inside a test effect; the selected groups are taken from the environment (provide their HttpApiBuilder.group layers). See Testing for the broader testing story.


Server-side assembly: turning groups and endpoints into routes.

Registers an entire HttpApi onto the ambient HttpRouter, requiring every group’s handler layer. Accepts { openapiPath } to additionally serve the OpenAPI document. (See options above.)

import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const ApiRoutes = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })
// => Layer<never, never, HttpRouter | ToService<...> | platform services>

Implements all endpoints in one group. The build callback receives a mutable Handlers instance; call handlers.handle(name, handler) (or handleRaw) for each endpoint. The result Layer provides that group’s ApiGroup service.

import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
export const SystemApiHandlers = HttpApiBuilder.group(
Api,
"system",
Effect.fn(function*(handlers) {
return handlers.handle("health", () => Effect.void)
})
)
// => Layer<ApiGroup<"Api", "system">, ...>

The mutable handler collection passed to group’s callback. Its methods refine the type-level set of not-yet-implemented endpoints:

  • handle(name, handler, options?) — implement an endpoint with automatic payload decoding and success encoding.
  • handleRaw(name, handler, options?) — opt out of automatic payload decoding and receive the raw request (e.g. for streaming bodies).

Both accept { uninterruptible?: boolean }. The Handlers.Item, Handlers.Error, and Handlers.Context helper types describe the registered handler and the error and service channels it contributes.

import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
export const UsersApiHandlers = HttpApiBuilder.group(
Api,
"users",
Effect.fn(function*(handlers) {
return handlers
.handle("getById", (req) => getUser(req.params.id))
.handle("create", (req) => createUser(req.payload))
})
)
declare function getUser(id: unknown): Effect.Effect<any>
declare function createUser(payload: unknown): Effect.Effect<any>

Builds the server-side HTTP Effect for a single endpoint, given its group name, endpoint name, and a handler. Useful when mounting one endpoint manually rather than a whole group via layer.

import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const healthRoute = HttpApiBuilder.endpoint(Api, "system", "health", () => Effect.void)
// => Effect<Effect<HttpServerResponse, never, ...>, never, ...>

Lower-level helper that converts a group + a Handlers.Item + a Context into an HttpRouter.Route. This is the primitive layer and HttpApiTest use internally; reach for it only when building custom routing on top of HttpApi.

import { HttpApiBuilder } from "effect/unstable/httpapi"
// HttpApiBuilder.handlerToRoute(group, handlerItem, context)
// => HttpRouter.Route — the method + path + compiled handler effect

Client-side derivation: turning groups and endpoints into callable methods.

The common entry point. Takes the HttpClient from the Effect environment and returns the full client. Options: transformClient (adjust the underlying HttpClient), transformResponse (wrap the per-call decode effect), and baseUrl (prepended to every request URL).

import { Effect } from "effect"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const make = HttpApiClient.make(Api, { baseUrl: "http://localhost:3000" })
// => Effect<Client<...>, never, HttpClient | MiddlewareClient<...>>

Like make, but takes an explicit HttpClient ({ httpClient }) instead of reading it from the environment — for when you already hold a concrete or transformed client. Supports transformResponse and baseUrl. This is what HttpApiTest.groups uses with its in-memory client.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const makeWith = HttpClient.HttpClient.pipe(
Effect.flatMap((httpClient) =>
HttpApiClient.makeWith(Api, { httpClient, baseUrl: "http://localhost:3000" })
)
)

Builds the typed client object for one group only, filtering the API down to that group. Returns a Client.Group (the nested object of endpoint methods).

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const usersClient = HttpClient.HttpClient.pipe(
Effect.flatMap((httpClient) =>
HttpApiClient.group(Api, { group: "users", httpClient })
)
)
// usersClient.getById({ params: { id } }), usersClient.create({ payload })

Builds the typed method for a single endpoint in a group. Returns just the callable function. Supports transformClient, transformResponse, and baseUrl.

import { Effect } from "effect"
import { HttpClient } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
const getById = HttpClient.HttpClient.pipe(
Effect.flatMap((httpClient) =>
HttpApiClient.endpoint(Api, { group: "users", endpoint: "getById", httpClient })
)
)
// yield* getById({ params: { id } }) => User

Mirrors the client layout but returns URL strings instead of executing requests — it encodes only params and query. Useful for links, redirects, or constructing requests by hand. Accepts an optional baseUrl.

import { Schema } from "effect"
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
const Api = HttpApi.make("Api").add(
HttpApiGroup.make("users").add(
HttpApiEndpoint.get("getUser", "/users/:id", {
params: { id: Schema.String }
})
)
)
const buildUrl = HttpApiClient.urlBuilder(Api, { baseUrl: "https://api.example.com" })
buildUrl.users.getUser({ params: { id: "123" } })
// => "https://api.example.com/users/123"

Internal driver shared by make, makeWith, group, and endpoint. It reflects over the API, building per-endpoint encode/decode functions and invoking onGroup / onEndpoint callbacks. You will not normally call it directly — use one of the constructors above.

Client types: Client, ForApi, Client.Group, Client.Method, UrlBuilder

Section titled “Client types: Client, ForApi, Client.Group, Client.Method, UrlBuilder”
  • Client<Groups, E, R> — the generated client object: nested objects for non-topLevel groups, methods on the root for topLevel group endpoints.
  • ForApi<Api, E, R> — derives Client straight from an HttpApi type. Use it as the service shape: Context.Service<ApiClient, HttpApiClient.ForApi<typeof Api>>.
  • Client.Group<Groups, Name, E, R> — the object for one group (returned by HttpApiClient.group).
  • Client.Method<Endpoint, E, R> — the function generated for one endpoint; generic over the response Mode.
  • Client.ResponseMode / Client.Response<Success, Mode> — the per-call responseMode type and the value it produces.
  • UrlBuilder<Api> — the shape returned by urlBuilder.
import { Context } from "effect"
import { HttpApiClient } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
export class ApiClient extends Context.Service<
ApiClient,
HttpApiClient.ForApi<typeof Api>
>()("acme/ApiClient") {}

Creates an in-memory client for the named groups of an HttpApi. Handlers for the selected groups are taken from the environment (provide their HttpApiBuilder.group layers); endpoints in unselected groups are wired with placeholder handlers that die if called, catching accidental out-of-scope calls.

The returned client is a real HttpApiClient — middleware, platform services, and the optional baseUrl behave exactly as in production; only the HTTP transport is replaced with an in-memory router dispatch.

import { Effect } from "effect"
import { HttpApiTest } from "effect/unstable/httpapi"
import { Api } from "../api/Api.ts"
import { UsersApiHandlers } from "../server/Users/http.ts"
const test = Effect.gen(function*() {
// baseUrl is optional; defaults to "http://localhost:3000"
const client = yield* HttpApiTest.groups(Api, ["users"], {
baseUrl: "http://localhost:3000"
})
const created = yield* client.users.create({
payload: { name: "Ada", email: "ada@acme.dev" }
})
// => User (driven through the real handler, in memory)
}).pipe(Effect.provide(UsersApiHandlers))

The next step is publishing the contract as OpenAPI.