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.

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)

For environments that speak the Fetch API (edge functions, workers), produce a (request: Request) => Promise<Response> handler instead of binding a port:

import { Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
// `layerServices` supplies the platform services a router needs without a server
export const { handler, dispose } = HttpRouter.toWebHandler(
AllRoutes.pipe(Layer.provide(HttpServer.layerServices))
)

HttpApiClient.make(Api) builds a client whose shape exactly follows the API: groups become namespaces, endpoints become methods, and topLevel groups attach their methods 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 `requiredForClient` 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))

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. See Testing for the broader testing story.

The next step is publishing the contract as OpenAPI.