Skip to content

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.

You’ll need:

  • Node 24 and pnpm (the repo pins both; pnpm install refuses an older Node).

  • The repository, cloned and installed:

    Terminal window
    git clone https://github.com/SeanSanker/forge-effect
    cd 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:

Terminal window
cd packages/infra
mkdir example/my-guestbook

A Worker is a handler and a name. In forge-effect, the handler is an Effect that returns a Response:

example/my-guestbook/guestbook.ts
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:

example/my-guestbook/app.ts
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:

Terminal window
pnpm infra dev example/my-guestbook/app.ts
example/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
Terminal window
$ curl localhost:8787
Hello 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.

A guestbook needs somewhere to keep the entries. Declare a D1 database:

example/my-guestbook/guestbook.ts
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* Book gives you a typed D1 client whose methods return Effects.

Now two handlers: one lists the newest entries, one signs the book.

example/my-guestbook/guestbook.ts
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:

example/my-guestbook/guestbook.ts
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.ts
error 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:

example/my-guestbook/guestbook.ts
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:

Terminal window
$ curl -X POST localhost:8787 -d '{"name": "Ada", "message": "First!"}'
$ curl localhost:8787
[{"name":"Ada","message":"First!","at":"2026-10-17T01:57:28.873Z"}]

Here’s a test for the guestbook. It runs your real handler, against a real SQLite database in memory, with your migration applied:

test/my-guestbook.test.ts
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!/);
});
Terminal window
$ 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, …).

The internet will find your guestbook. Let’s allow each address three signatures a minute. Declare a rate limit beside the database:

example/my-guestbook/guestbook.ts
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:

example/my-guestbook/guestbook.ts
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 before

Your editor complains again, as it should: sign uses Signing now. Bind it, and you’re done:

example/my-guestbook/guestbook.ts
export default Worker.make('guestbook', { fetch }).bind(Book, Signing);

Your test needs a rate limit now too. RateLimit.memory counts in memory:

test/my-guestbook.test.ts
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.

Time to go live. Make an API token with Workers Scripts Edit and D1 Edit, and put it in your environment:

Terminal window
export CLOUDFLARE_API_TOKEN=… # and CLOUDFLARE_ACCOUNT_ID, if the token reaches several accounts

First, ask what a deploy would do. plan changes nothing:

$ pnpm infra plan example/my-guestbook/app.ts
guestbook (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.dev

Eight seconds. Notice the smoke test: ship didn’t stop at uploading, it called your Worker and checked that the new version answered.

Terminal window
$ 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. 🎉

Change anything (the 400 message, say) and plan again:

$ pnpm infra plan example/my-guestbook/app.ts
guestbook (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.

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.dev

A 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-book

Production is guarded more carefully, since its data is real:

$ pnpm infra destroy example/my-guestbook/app.ts --yes
stopped: tearing down production deletes its data; pass --allow-data-loss if that's meant

Add --allow-data-loss when you mean it.

  • 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.