1---
2title: Effect HTTP API
3description: Build a schema-validated HTTP API with Effect's HttpApi module and deploy it as a Cloudflare Worker.
4sidebar:
5 order: 3
6---
7
8Effect HTTP is the trust-boundary modality: schema-validated REST
9endpoints — real URLs, path params, query strings, payloads — that
10any HTTP client can call, no Effect or TypeScript required on the
11consumer's side. This page deploys one to a Cloudflare Worker; the
12concept home is [Effect HTTP](/apis/effect-http), and
13[RPC](/apis) covers choosing between the modalities.
14
15In the [tutorial](/cloudflare/tutorial/part-2), you built a Worker with manual
16`if/else` routing and raw request parsing. That works, but as your
17API grows you lose type safety at the boundary — request payloads
18aren't validated, response shapes aren't enforced, and errors slip
19through untyped.
20
21Effect's `HttpApi` module solves this. You declare endpoints with
22schemas for payloads, responses, and errors, then implement handlers
23against those schemas. The result is an `HttpEffect` — the same type
24a Worker's `fetch` expects — so it plugs in directly.
25
26The mental model we'll follow is:
27
281. **Define the schema and API outside the Worker.** Both are pure
29 descriptions and can be imported by clients.
302. **Construct the service inside the Worker's Construction phase.**
31 The Construction phase runs at plan time *and* runtime, so we only do
32 pure construction here — we never `yield*` something that needs
33 a request to exist.
343. **Return `{ fetch }`** where `fetch` is an `HttpEffect` produced
35 by `HttpRouter.toHttpEffect`. That's the value Workers invoke on
36 every request.
374. **Bonus:** deploy, grab the URL, and call the API from a fully
38 typed client.
39
40## 1. Define the schema
41
42Start with a domain model. The schema lives outside the Worker so
43the same file can be imported by clients without pulling in any
44runtime code.
45
46```typescript
47// src/task.ts
48import * as Schema from "effect/Schema";
49
50export class Task extends Schema.Class<Task>("Task")({
51 id: Schema.String,
52 title: Schema.String,
53 completed: Schema.Boolean,
54}) {}
55
56export class TaskNotFound extends Schema.TaggedErrorClass<TaskNotFound>()(
57 "TaskNotFound",
58 { id: Schema.String },
59 { httpApiStatus: 404 },
60) {}
61```
62
63`Schema.Class` gives you a runtime-validated class with an inferred
64TypeScript type. `Schema.TaggedErrorClass` gives you a typed error you
65can return from handlers and discriminate against on the client —
66`httpApiStatus: 404` maps it to a 404 response instead of a generic 500.
67
68## 2. Declare the API
69
70Endpoints are declarations — they describe `(method, path,
71params, payload, success, error)` without implementing anything yet. Putting
72them in their own file keeps the spec importable from both the
73server *and* a typed client.
74
75```typescript
76// src/api.ts
77import * as Schema from "effect/Schema";
78import * as HttpApi from "effect/http-api/HttpApi";
79import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
80import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
81import { Task, TaskNotFound } from "./task.ts";
82
83export const decodeTask = Schema.decodeUnknownEffect(Task);
84export const encodeTask = Schema.encodeUnknownSync(Task);
85
86const TaskParams = Schema.Struct({
87 id: Schema.String,
88});
89
90const getTask = HttpApiEndpoint.get("getTask", "/:id", {
91 params: TaskParams,
92 success: Task,
93 error: TaskNotFound,
94});
95
96const createTask = HttpApiEndpoint.post("createTask", "/", {
97 success: Task,
98 payload: Schema.Struct({
99 title: Schema.String,
100 }),
101});
102
103export class TasksGroup extends HttpApiGroup.make("Tasks")
104 .add(getTask)
105 .add(createTask) {}
106
107export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {}
108```
109
110Nothing executes yet — `TaskApi` is purely a value-level description.
111The same `TaskApi` constant is what we'll hand to the client at the
112end of this guide.
113
114## 3. Build the Worker
115
116Now we wire it up. Create `src/worker.ts` with an empty Construction phase:
117
118```typescript
119// src/worker.ts
120import * as Cloudflare from "alchemy/Cloudflare";
121import * as Effect from "effect/Effect";
122
123export default Cloudflare.Worker(
124 "Worker",
125 { main: import.meta.url },
126 Effect.gen(function* () {
127 return {};
128 }),
129);
130```
131
132The generator inside `Cloudflare.Worker` is the **Construction phase**. It
133runs both at *plan time* (when Alchemy builds the deployment graph)
134and at *runtime* (when the Worker boots a fresh isolate). Anything
135you `yield*` here must be safe in both contexts — typically resource
136binding factories like `R2.ReadWriteBucket(...)`, never per-request work.
137
138### 3a. Bind an R2 bucket for storage
139
140Tasks need to live somewhere durable. Declare an `Bucket` resource
141and bind it inside the constructor — `bind()` returns a typed handle whose
142`get` / `put` / `delete` / `list` methods we'll call from the
143handlers below.
144
145```typescript
146// src/bucket.ts
147import * as Cloudflare from "alchemy/Cloudflare";
148
149export const Tasks = Cloudflare.R2.Bucket("Tasks");
150```
151
152```diff lang="typescript"
153+import { Tasks } from "./bucket.ts";
154
155export default Cloudflare.Worker(
156 "Worker",
157 { main: import.meta.url },
158 Effect.gen(function* () {
159+ const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
160
161 return {};
162 }),
163);
164```
165
166We'll provide the runtime side of this binding
167(`Cloudflare.R2.ReadWriteBucketBinding`) in step 3c when we wire up the
168`fetch` handler.
169
170### 3b. Construct the handler group inside the constructor
171
172`HttpApiBuilder.group` *constructs* a `Layer` that wires handlers
173into the API spec. It's pure — it doesn't run them. That makes it
174safe to call inside the constructor.
175
176> Don't `yield*` `HttpApiBuilder.layer(TaskApi)` here — building the
177> layer is fine, but actually executing the server requires an
178> incoming request. The constructor only does construction; the work happens
179> later, on each `fetch` call.
180
181```diff lang="typescript"
182 Effect.gen(function* () {
183 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
184
185+ const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
186+ handlers
187+ .handle("getTask", ({ params }) =>
188+ Effect.gen(function* () {
189+ const object = yield* tasks.get(params.id);
190+ if (!object) {
191+ return yield* Effect.fail(new TaskNotFound({ id: params.id }));
192+ }
193+ return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text()));
194+ }).pipe(Effect.orDie),
195+ )
196+ .handle("createTask", ({ payload }) =>
197+ Effect.gen(function* () {
198+ const task = new Task({
199+ id: crypto.randomUUID(),
200+ title: payload.title,
201+ completed: false,
202+ });
203+ yield* tasks.put(task.id, JSON.stringify(task));
204+ return task;
205+ }).pipe(Effect.orDie),
206+ ),
207+ );
208
209 return {};
210 }),
211```
212
213Each handler receives a typed request. `params.id` is a `string`
214because that's what the endpoint declared, and the return type must
215satisfy `Task` (or fail with `TaskNotFound`). Mismatches are caught
216at compile time.
217
218`tasks.get` and `tasks.put` can fail with `R2Error`, which isn't in
219either endpoint's declared error set. `Effect.orDie` converts those
220into defects so the HttpApi runtime turns them into 500 responses —
221keeping the typed error channel reserved for `TaskNotFound`.
222
223### 3c. Return `{ fetch }`
224
225The return value of the constructor is the *Worker's surface* — for a
226fetch-style Worker that means an object with a `fetch` field. The
227value of `fetch` must be an `HttpEffect`: an `Effect` that, given an
228`HttpServerRequest`, produces an `HttpServerResponse`.
229
230We assemble it in three layers, then convert:
231
232- `HttpApiBuilder.layer(TaskApi)` — the top-level API layer.
233- `Layer.provide(tasksGroup)` — plug in the handlers we just built.
234- `Layer.provide([Etag.layer, HttpPlatformStub, Path.layer])` —
235 platform services the builder needs (content negotiation, ETag
236 generation, path handling).
237- `yield* HttpRouter.toHttpEffect(...)` — build the assembled Layer
238 and yield the `HttpEffect`.
239
240`HttpPlatformStub` stands in for `HttpPlatform.layer`, which requires
241a `FileSystem` and therefore can't run on Workers — there is no
242filesystem in the isolate. Define it once at module scope:
243
244```typescript
245const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, {
246 fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"),
247 fileWebResponse: () =>
248 Effect.die("HttpPlatform.fileWebResponse not supported"),
249});
250```
251
252```diff lang="typescript"
253 return {
254+ fetch: yield* HttpRouter.toHttpEffect(
255+ HttpApiBuilder.layer(TaskApi).pipe(
256+ Layer.provide(tasksGroup),
257+ Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
258+ ),
259+ ),
260 };
261```
262
263`HttpRouter.toHttpEffect` returns an `Effect` that builds the Layer
264and hands back the request handler — yielding it once in the constructor means
265route construction happens at boot, not per request.
266
267### 3d. Enable CORS
268
269`HttpRouter.cors(...)` returns a `Layer` that registers a global
270router middleware. Plug it in alongside the platform services with
271another `Layer.provide`.
272
273```diff lang="typescript"
274 return {
275 fetch: yield* HttpRouter.toHttpEffect(
276 HttpApiBuilder.layer(TaskApi).pipe(
277 Layer.provide(tasksGroup),
278 Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
279+ Layer.provide(HttpRouter.cors({
280+ allowedOrigins: ["*"],
281+ allowedMethods: ["GET", "POST", "OPTIONS"],
282+ allowedHeaders: ["Content-Type"],
283+ })),
284 ),
285 ),
286 };
287```
288
289The middleware wraps every response — including the automatic
290`OPTIONS` preflight — with the configured `Access-Control-*` headers.
291Because it's a `Layer<never, never, HttpRouter>`, it composes the same
292way as the rest of the API stack: order doesn't matter, and there's
293no per-handler plumbing.
294
295### Putting it together
296
297Here's the complete `src/worker.ts`:
298
299```typescript
300// src/worker.ts
301import * as Cloudflare from "alchemy/Cloudflare";
302import * as Effect from "effect/Effect";
303import * as Layer from "effect/Layer";
304import * as Path from "effect/Path";
305import * as Schema from "effect/Schema";
306import * as Etag from "effect/http/Etag";
307import * as HttpPlatform from "effect/http/HttpPlatform";
308import * as HttpRouter from "effect/http/HttpRouter";
309import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
310import { TaskApi } from "./api.ts";
311import { Tasks } from "./bucket.ts";
312import { Task, TaskNotFound } from "./task.ts";
313
314const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, {
315 fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"),
316 fileWebResponse: () =>
317 Effect.die("HttpPlatform.fileWebResponse not supported"),
318});
319
320export default Cloudflare.Worker(
321 "Worker",
322 { main: import.meta.url },
323 Effect.gen(function* () {
324 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
325
326 const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
327 handlers
328 .handle("getTask", ({ params }) =>
329 Effect.gen(function* () {
330 const object = yield* tasks.get(params.id);
331 if (!object) {
332 return yield* Effect.fail(new TaskNotFound({ id: params.id }));
333 }
334 return Schema.decodeUnknownSync(Task)(
335 JSON.parse(yield* object.text()),
336 );
337 }).pipe(Effect.orDie),
338 )
339 .handle("createTask", ({ payload }) =>
340 Effect.gen(function* () {
341 const task = new Task({
342 id: crypto.randomUUID(),
343 title: payload.title,
344 completed: false,
345 });
346 yield* tasks.put(task.id, JSON.stringify(task));
347 return task;
348 }).pipe(Effect.orDie),
349 ),
350 );
351
352 return {
353 fetch: yield* HttpRouter.toHttpEffect(
354 HttpApiBuilder.layer(TaskApi).pipe(
355 Layer.provide(tasksGroup),
356 Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
357 Layer.provide(
358 HttpRouter.cors({
359 allowedOrigins: ["*"],
360 allowedMethods: ["GET", "POST", "OPTIONS"],
361 allowedHeaders: ["Content-Type"],
362 }),
363 ),
364 ),
365 ),
366 };
367 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
368);
369```
370
371## 4. Add to the Stack
372
373```typescript
374// alchemy.run.ts
375import * as Alchemy from "alchemy";
376import * as Cloudflare from "alchemy/Cloudflare";
377import * as Effect from "effect/Effect";
378import Worker from "./src/worker.ts";
379
380export default Alchemy.Stack(
381 "TaskApi",
382 { providers: Cloudflare.providers(), state: Cloudflare.state() },
383 Effect.gen(function* () {
384 const worker = yield* Worker;
385 return { url: worker.url };
386 }),
387);
388```
389
390## 5. Deploy
391
392```sh
393alchemy deploy
394```
395
396You can hit it directly with `curl`:
397
398```sh
399curl -X POST https://your-worker.workers.dev/ \
400 -H "Content-Type: application/json" \
401 -d '{"title": "Write docs"}'
402# → {"id":"...","title":"Write docs","completed":false}
403
404curl https://your-worker.workers.dev/<id>
405# → {"id":"...","title":"Write docs","completed":false}
406```
407
408Invalid payloads get automatic 400 responses with structured
409validation errors — no manual checking needed.
410
411## Bonus: call it from a typed client
412
413Because `TaskApi` is just a value, the same spec drives a fully
414typed client. There's no codegen step — `HttpApiClient.make`
415produces methods whose argument and return types come straight from
416the endpoint schemas.
417
418```typescript
419// scripts/client.ts
420import * as Effect from "effect/Effect";
421import * as HttpApiClient from "effect/http-api/HttpApiClient";
422import { TaskApi } from "../src/api.ts";
423
424const program = Effect.gen(function* () {
425 const client = yield* HttpApiClient.make(TaskApi, {
426 baseUrl: process.env.TASK_API_URL!,
427 });
428
429 const created = yield* client.Tasks.createTask({
430 payload: { title: "Write docs" },
431 });
432 console.log("Created:", created.id);
433
434 const fetched = yield* client.Tasks.getTask({
435 params: { id: created.id },
436 });
437 console.log("Fetched:", fetched.title);
438});
439
440Effect.runPromise(program);
441```
442
443Get the URL from the deploy output and run it:
444
445```sh
446TASK_API_URL=https://your-worker.workers.dev bun scripts/client.ts
447```
448
449`client.Tasks.getTask` returns `Effect<Task, TaskNotFound | HttpClientError>`.
450The `TaskNotFound` branch is a real typed value you can pattern-match
451on, not an HTTP status code you have to interpret.
452
453## Bonus: route some endpoints to a Durable Object
454
455The same `HttpApi` machinery can run *inside* a Durable Object. The
456DO becomes a typed sub-API that the Worker delegates to, while the
457public `TaskApi` shape remains a single value clients can import.
458
459### Define a DO-side group
460
461The DO exposes its own group built from the same endpoint
462declarations. Add two more endpoints to the public spec — one for
463`get` and one for `create` — that the Worker will proxy to the DO,
464then make a private group for the DO with just the two original
465endpoints:
466
467```typescript
468// src/api.ts
469const getTaskDO = HttpApiEndpoint.get("getTaskDO", "/do/:id", {
470 params: TaskParams,
471 success: Task,
472 error: TaskNotFound,
473});
474
475const createTaskDO = HttpApiEndpoint.post("createTaskDO", "/do", {
476 success: Task,
477 payload: Schema.Struct({ title: Schema.String }),
478});
479
480export class TasksGroup extends HttpApiGroup.make("Tasks")
481 .add(getTask)
482 .add(createTask)
483 .add(getTaskDO)
484 .add(createTaskDO) {}
485
486export class TasksDOGroup extends HttpApiGroup.make("TasksDO")
487 .add(getTask)
488 .add(createTask) {}
489
490export class TaskDOApi extends HttpApi.make("TaskDOApi").add(TasksDOGroup) {}
491```
492
493### Implement the DO
494
495The DO's constructor returns `{ fetch }` — an `HttpEffect` produced exactly
496the same way the Worker produces one, just scoped to its own
497`TaskDOApi`. Storage is the DO's transactional `state.storage`
498instead of R2.
499
500```typescript
501// src/object.ts
502import * as Cloudflare from "alchemy/Cloudflare";
503import * as Effect from "effect/Effect";
504import * as Layer from "effect/Layer";
505import * as Path from "effect/Path";
506import * as Etag from "effect/http/Etag";
507import * as HttpPlatform from "effect/http/HttpPlatform";
508import * as HttpRouter from "effect/http/HttpRouter";
509import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
510import { TaskDOApi, decodeTask, encodeTask } from "./api.ts";
511import { Task } from "./task.ts";
512
513const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, {
514 fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"),
515 fileWebResponse: () =>
516 Effect.die("HttpPlatform.fileWebResponse not supported"),
517});
518
519export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()(
520 "TasksObject",
521 Effect.gen(function* () {
522 const state = yield* Cloudflare.DurableObjectState;
523 return Effect.gen(function* () {
524 const tasksGroup = HttpApiBuilder.group(TaskDOApi, "TasksDO", (handlers) =>
525 handlers
526 .handle("getTask", ({ params }) =>
527 state.storage.get<Task>(params.id).pipe(
528 Effect.flatMap(decodeTask),
529 Effect.orDie,
530 ),
531 )
532 .handle("createTask", ({ payload }) => {
533 const task = new Task({
534 id: crypto.randomUUID(),
535 title: payload.title,
536 completed: false,
537 });
538 return state.storage.put(task.id, encodeTask(task)).pipe(Effect.as(task));
539 }),
540 );
541
542 return {
543 fetch: yield* HttpRouter.toHttpEffect(
544 HttpApiBuilder.layer(TaskDOApi).pipe(
545 Layer.provide(tasksGroup),
546 Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
547 ),
548 ),
549 };
550 });
551 }),
552) {}
553```
554
555### Bridge the DO into a typed client
556
557Inside the Worker's constructor, `Cloudflare.toHttpClient(stub)` wraps a DO
558stub as an `HttpClient`. Hand that client to `HttpApiClient.makeWith`
559and you get a fully typed client whose every call is a DO `fetch`
560under the hood:
561
562```diff lang="typescript"
563+import * as HttpApiClient from "effect/http-api/HttpApiClient";
564+import TasksObject, { TaskDOApi } from "./object.ts";
565
566 Effect.gen(function* () {
567 const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
568+ const tasksDO = yield* TasksObject;
569+
570+ const getTaskDOClient = (id: string = "default") =>
571+ HttpApiClient.makeWith(TaskDOApi, {
572+ baseUrl: "http://localhost",
573+ httpClient: Cloudflare.toHttpClient(tasksDO.getByName(id)),
574+ });
575```
576
577The `baseUrl` is irrelevant — requests never leave the isolate; the
578`httpClient` short-circuits straight to `tasksDO.getByName(id).fetch`.
579
580### Wire the proxy handlers
581
582The Worker's two new handlers just construct a per-request DO client
583and forward the typed call. Errors from the DO already match the
584public endpoint's error schema, so there's no remapping:
585
586```diff lang="typescript"
587 const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
588 handlers
589 .handle("getTask", /* ... R2 implementation ... */)
590 .handle("createTask", /* ... R2 implementation ... */)
591+ .handle("getTaskDO", ({ params }) =>
592+ getTaskDOClient().pipe(
593+ Effect.flatMap((client) => client.TasksDO.getTask({ params })),
594+ ),
595+ )
596+ .handle("createTaskDO", ({ payload }) =>
597+ getTaskDOClient().pipe(
598+ Effect.flatMap((client) => client.TasksDO.createTask({ payload })),
599+ ),
600+ ),
601 );
602```
603
604`GET /do/:id` and `POST /do` now hit the DO; `GET /:id` and `POST /`
605still hit R2. Same `TaskApi`, same client, two different storage
606backends behind the scenes.
607
608## Recap
609
610- The **schema** (`Task`, `TaskNotFound`) and the **API spec**
611 (`TaskApi`) live outside the Worker — they're pure descriptions.
612- The **handlers** are constructed inside the Worker's Construction phase
613 closure. We build a `Layer` with `HttpApiBuilder.group` but never
614 `yield*` the running server — that only makes sense per-request.
615- The Worker's surface is `{ fetch }`, where `fetch` is an
616 `HttpEffect` yielded once from `HttpRouter.toHttpEffect`.
617- The same `TaskApi` value drives a fully typed client via
618 `HttpApiClient.make` — no codegen, no string URLs.
619
620## Where next
621
622- [Effect HTTP](/apis/effect-http) — the concept home for this modality.
623- [RPC](/apis) — how to choose between Schemaless RPC, Effect RPC, and
624 Effect HTTP.
625- [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — the
626 schema-first RPC shape for Effect/TypeScript consumers.
627- [Schemaless RPC](/cloudflare/compute/workers#call-another-worker) — the
628 default for internal Worker-to-Worker calls.