Build your first app
You’re going to build a guestbook: visitors leave their name and a message, and anyone can read them back. It’s small, but it touches everything you’ll use every day:
- a Worker written in Effect,
- a D1 database with a migration,
- a rate limit, so nobody floods it,
- tests that run in memory, with no mocks,
- your whole app running locally in workerd,
- and a deploy to your Cloudflare account, then a preview copy, then a clean teardown.
The finished app is
packages/infra/example/guestbook,
if you’d like to peek at the end.
Before you start
Section titled “Before you start”You’ll need:
-
Node 24 and pnpm (the repo pins both;
pnpm installrefuses an older Node). -
The repository, cloned and installed:
Terminal window git clone https://github.com/SeanSanker/forge-effectcd forge-effect && pnpm install -
A Cloudflare account, but only for the deploy at the end. Everything before it runs on your machine.
Everything below runs from packages/infra, where the infra command
lives. Make a folder for your app there:
cd packages/inframkdir example/my-guestbookStep 1: Hello, Worker
Section titled “Step 1: Hello, Worker”A Worker is a handler and a name. In forge-effect, the handler is an
Effect that returns a Response:
import { Effect } from 'effect';
import { Worker } from '@forge-effect/infra';
const fetch = Effect.sync(() => new Response('Hello from the guestbook!\n'));
export default Worker.make('guestbook', { fetch }).bind();An app is the set of Workers you plan, ship and tear down together. Ours has one:
import { App } from '@forge-effect/infra';
import guestbook from './guestbook.ts';
export default App.make('guestbook', { workers: [guestbook] });That’s a complete app. Run it:
pnpm infra dev example/my-guestbook/app.tsexample/my-guestbook/app.ts, locally (state in .infra/dev, beside it): guestbook http://guestbook.localhost:8787/ and http://127.0.0.1:8787/ goes to guestbook$ curl localhost:8787Hello from the guestbook!That’s your Worker, running in workerd, the same runtime Cloudflare runs
it in. Leave infra dev running: it rebuilds whenever you save.
Step 2: Give it a memory
Section titled “Step 2: Give it a memory”A guestbook needs somewhere to keep the entries. Declare a D1 database:
import { D1, Worker } from '@forge-effect/infra';
/** The entries, in D1. Its migrations are its schema. */export class Book extends D1.Database<Book>()('book', { migrations: ['CREATE TABLE entries (id INTEGER PRIMARY KEY, name TEXT NOT NULL, message TEXT NOT NULL, at TEXT NOT NULL)'],}) {}This one class is two things at once, and this is the idea everything else in forge-effect builds on:
- A declaration. “This app has a D1 database called
book, and this is its schema.” When you deploy, the database is made and the migration applied. - A client. Inside a handler,
yield* Bookgives you a typed D1 client whose methods return Effects.
Now two handlers: one lists the newest entries, one signs the book.
import { DateTime, Effect, Schema } from 'effect';
const Entry = Schema.Struct({ name: Schema.String, message: Schema.String });
const list = Effect.gen(function* () { const book = yield* Book; const entries = yield* book.all<{ name: string; message: string; at: string }>( 'SELECT name, message, at FROM entries ORDER BY id DESC LIMIT 50', ); return Response.json(entries);});
const sign = Effect.gen(function* () { const request = yield* Worker.Request; // Not JSON, or not the right shape: None. const entry = yield* Effect.tryPromise(() => request.json()).pipe( Effect.flatMap(Schema.decodeUnknownEffect(Entry)), Effect.option, ); if (entry._tag === 'None' || entry.value.name.trim() === '' || entry.value.message.trim() === '') { return new Response('send {"name": "…", "message": "…"}\n', { status: 400 }); } const { name, message } = entry.value; yield* (yield* Book).run( 'INSERT INTO entries (name, message, at) VALUES (?, ?, ?)', name.trim(), message.trim(), DateTime.formatIso(yield* DateTime.now), ); return new Response(null, { status: 201 });});
const fetch = Effect.gen(function* () { const request = yield* Worker.Request; return yield* request.method === 'POST' ? sign : list;}).pipe(Effect.orDie);Worker.Request is the incoming request, as a service: handlers ask for
what they need, rather than having it passed in. Effect.orDie at the end
says “a database failure here is a bug, not something I’ll handle”: the
Worker answers 500 and logs it.
Now save, with the Worker as it was:
export default Worker.make('guestbook', { fetch }).bind();…and your editor lights up. The handlers use Book, but the Worker
doesn’t bind it, so on Cloudflare it would have no database to talk to.
Ask infra check, and it says what’s wrong, where, and how to fix it:
$ pnpm infra check example/my-guestbook/app.tserror used-undeclared example/my-guestbook/guestbook.ts:43:16 the Worker uses Book, which it doesn't bind fix: bind it: .bind(…, Book)This is a type error, not a runtime check. The compiler knows what every handler needs, because Effect tracks it in the types, and it won’t let a Worker go out without it. Bind it:
export default Worker.make('guestbook', { fetch }).bind(Book);infra dev has already rebuilt, and applied the migration to a local
database (kept in .infra/dev, so it survives restarts). Try it:
$ curl -X POST localhost:8787 -d '{"name": "Ada", "message": "First!"}'$ curl localhost:8787[{"name":"Ada","message":"First!","at":"2026-10-17T01:57:28.873Z"}]Step 3: Test it, no mocks
Section titled “Step 3: Test it, no mocks”Here’s a test for the guestbook. It runs your real handler, against a real SQLite database in memory, with your migration applied:
import assert from 'node:assert/strict';import { test } from 'node:test';
import { Effect, ManagedRuntime } from 'effect';
import { Worker } from '@forge-effect/infra';import { sqlite } from '@forge-effect/infra/testing';import guestbook, { Book } from '../example/my-guestbook/guestbook.ts';
test('signed, then listed', async () => { // One database for every request, as one Worker has. const runtime = ManagedRuntime.make(sqlite(Book)); const call = (method: string, body?: unknown) => runtime.runPromise( Effect.flatMap( Worker.handle(guestbook, new Request('https://x.dev/', { method, body: body === undefined ? null : JSON.stringify(body) })), (r) => Effect.promise(async () => ({ status: r.status, body: await r.text() })), ), ); assert.equal((await call('POST', { name: 'Ada', message: 'Hello!' })).status, 201); assert.equal((await call('POST', { name: 'Ada' })).status, 400); assert.match((await call('GET')).body, /Hello!/);});$ node --import ../ship/scripts/register.ts --test test/my-guestbook.test.ts✔ signed, then listed (16ms)sqlite(Book) is a Layer that provides Book from Node’s built-in
SQLite. That’s the trick behind testing here: your handler asks for
Book, and in a test something else answers. Every resource has a
stand-in like this (Kv.memory, Queue.memory, RateLimit.memory, …).
Step 4: Keep it civil
Section titled “Step 4: Keep it civil”The internet will find your guestbook. Let’s allow each address three signatures a minute. Declare a rate limit beside the database:
import { D1, RateLimit, Worker } from '@forge-effect/infra';
/** Three signatures a minute from one address. */export class Signing extends RateLimit.Make<Signing>()('signing', { limit: 3, period: 60 }) {}…and check it at the top of sign:
const sign = Effect.gen(function* () { const request = yield* Worker.Request; const address = request.headers.get('cf-connecting-ip') ?? 'unknown'; if (!(yield* (yield* Signing).limit(address))) { return new Response('slow down: three a minute\n', { status: 429 }); } // … as beforeYour editor complains again, as it should: sign uses Signing now. Bind
it, and you’re done:
export default Worker.make('guestbook', { fetch }).bind(Book, Signing);Your test needs a rate limit now too. RateLimit.memory counts in memory:
const runtime = ManagedRuntime.make(Layer.mergeAll(sqlite(Book), RateLimit.memory(Signing)));(Import Layer from effect, RateLimit from @forge-effect/infra, and
Signing beside Book.) That’s the whole app, and it’s exactly
the finished example.
Step 5: Ship it
Section titled “Step 5: Ship it”Time to go live. Make an API token with Workers Scripts Edit and D1 Edit, and put it in your environment:
export CLOUDFLARE_API_TOKEN=… # and CLOUDFLARE_ACCOUNT_ID, if the token reaches several accountsFirst, ask what a deploy would do. plan changes nothing:
$ pnpm infra plan example/my-guestbook/app.tsguestbook (production)+ d1 book: guestbook-book~ the D1 database guestbook-book: the next ship applies migration 1: migration-pending+ Worker guestbook-guestbook: new, live at once~ rollout: 100%A new database, its migration, and a new Worker. Everything is named after
the app: guestbook-book, guestbook-guestbook. Looks right. Ship it:
$ pnpm infra ship example/my-guestbook/app.ts 1.28s shipping guestbook-guestbook 1.29s ✓ 739.1 KB of modules 2.01s ✓ created D1 database guestbook-book 2.47s ✓ applied migration 1 to D1 database guestbook-book 3.68s ✓ created Worker guestbook-guestbook, live 8.16s ✓ smoke test passed: 3 × GET /, answered by the new Worker 8.51s ✓ shipped guestbook-guestbook: https://guestbook-guestbook.you.workers.devEight seconds. Notice the smoke test: ship didn’t stop at uploading, it
called your Worker and checked that the new version answered.
$ curl -X POST https://guestbook-guestbook.you.workers.dev -d '{"name": "Ada", "message": "Hello from the edge!"}'$ curl https://guestbook-guestbook.you.workers.dev[{"name":"Ada","message":"Hello from the edge!","at":"2026-10-17T01:47:11.845Z"}]You’re live. 🎉
Ship a change
Section titled “Ship a change”Change anything (the 400 message, say) and plan again:
$ pnpm infra plan example/my-guestbook/app.tsguestbook (production)= d1 book: guestbook-book~ Worker guestbook-guestbook: a new version; now 100% on 3bd046c7~ rollout: 10% → 50% → 100%The database is unchanged (=). This time the Worker already serves real
traffic, so the new version rolls out gradually: 10% of requests, then
50%, then all of them, with smoke tests at each step. If a step fails, or
you press Ctrl-C, everything goes back the way it was.
Step 6: A copy for trying things
Section titled “Step 6: A copy for trying things”Want to try a change without touching production? Ship a copy under another environment name:
$ pnpm infra ship example/my-guestbook/app.ts --env pr-7 1.69s ✓ created D1 database guestbook-pr-7-book 2.18s ✓ applied migration 1 to D1 database guestbook-pr-7-book 3.43s ✓ created Worker guestbook-pr-7-guestbook, live 5.15s ✓ smoke test passed: 3 × GET /, answered by the new Worker 5.46s ✓ shipped guestbook-pr-7-guestbook: https://guestbook-pr-7-guestbook.you.workers.devA whole separate guestbook, with its own empty database. Perfect for a pull request’s preview. When you’re done with it:
$ pnpm infra destroy example/my-guestbook/app.ts --env pr-7 --yes✓ deleted worker guestbook-pr-7-guestbook✓ deleted d1 guestbook-pr-7-bookProduction is guarded more carefully, since its data is real:
$ pnpm infra destroy example/my-guestbook/app.ts --yesstopped: tearing down production deletes its data; pass --allow-data-loss if that's meantAdd --allow-data-loss when you mean it.
What you just did
Section titled “What you just did”- Declared a database and a rate limit as classes that are also their clients.
- Watched the compiler refuse a Worker that didn’t bind what it used.
- Tested the real handler against real SQLite, no mocks.
- Ran the app locally in workerd, migrations and all.
- Planned, shipped, smoke-tested and rolled out to Cloudflare, then made and destroyed a preview.
- How it thinks: the ideas behind what you just did, in one page.
- Storage: KV, D1 and R2 in depth.
- Or open the other examples: a link shortener, a chat with WebSockets, semantic search, a shop of Workers that call each other.