Tutorial: Your first app
One route and build(), then a full CRUD service: contracts, DI, config, and a test.
A running server you can curl, from a nine-line file, in about a minute. Three steps get you there: create a FlareHost with a runtime adapter, register one route on host.http, then call host.build() and .run() on Node (or .export() on Workers).
The app
Section titled “The app”Create src/main.ts:
import { FlareHost, FlareResponse } from "@flare-ts/core";import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.http.get("/ping", () => new FlareResponse(200, { ok: true }));
const app = host.build();app.run();Run it:
node src/main.tsThen request the route:
curl http://localhost:3000/ping# {"ok":true}The Node adapter binds port 3000 by default. Override it in flare.json (host.port) or with FLARE__host__port=4000. See Install for the full config pipeline.
What build() does
Section titled “What build() does”host.build() finalizes your registrations into a runnable app. It runs synchronously: this is composition, not bundling. On success you get an app instance. Call .run() on Node, .export() on Workers, or .test() on the built app when test mode is active. An accidental second call to build() returns the same cached instance.
If anything is misconfigured, build() throws before the server listens or exports a handler. See Failure modes for the error catalog and Host for the full build sequence.
Set FLARE_MODE=test before importing the host module to get a test app whose .test() method returns a TestAppHandle from @flare-ts/core/testing. See Testing.
A route with a typed parameter
Section titled “A route with a typed parameter”Without a descriptor, matched path segments are plain strings on ctx.req.rawRouteParams (Record<string, string>). Declare route, query, or body fields on the route options and read typed values from scope.input in the handler. For example, { route: { id: int } } makes scope.input.route.id a number. See HTTP contracts for the full descriptor shape and controller httpContract entries.
host.http.get accepts an options object before the handler:
import { FlareHost, FlareResponse } from "@flare-ts/core";import { node } from "@flare-ts/core/node";import { int } from "@flare-ts/lib/schema";
const host = new FlareHost(node);
host.http.get("/users/:id", { route: { id: int } }, (_ctx, scope) => { // scope.input.route.id is `number`, already validated return new FlareResponse(200, { id: scope.input.route.id });});
const app = host.build();app.run();curl http://localhost:3000/users/42# {"id":42}
curl http://localhost:3000/users/banana# 400 {"error":"Invalid route parameters. Check that your URL path matches the expected format."}banana is not an integer, so the value never parses, the handler never runs, and the request ends at the boundary with a 400. Route and query failures answer with the error message alone; a body failure adds a details array naming each field that failed. Failure modes lists every response the pipeline produces on its own, and HTTP contracts covers the descriptor surface behind them.
Build it into a CRUD service
Section titled “Build it into a CRUD service”The rest of this tutorial builds the next step up: a small CRUD service for an in-memory tasks resource. By the end you’ll have list, create, update, and delete routes on a controller, bodies validated before they reach a handler, a service that owns the storage, a limit you can change per environment without touching code, and a test that drives the assembled app.
Each step adds one capability and runs on its own. The controllers here use route decorators, which plain node can’t parse, so run each step with npx tsx src/main.ts and hit it with curl, or skip the server and jump to the test at the end.
Step 1: a controller with one route
Section titled “Step 1: a controller with one route”Start with a ControllerBase subclass instead of inline host.http.get calls. A controller groups routes that share a prefix, a contract, and services, which is exactly what a CRUD resource is. Mount it with host.http.controller(prefix, Cls).
Every controller declares two static members even when they’re empty: static deps (services it may inject) and static state (per-request state tokens it reads). Both are [] for now.
import { ControllerBase, FlareHost } from "@flare-ts/core";import { Get } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";
class TasksController extends ControllerBase { public static override deps = []; public static override state = [];
@Get("") list() { return this.ok({ tasks: [] }); }}
const host = new FlareHost(node);host.http.controller("/tasks", TasksController);
const app = host.build();app.run();@Get("") is the controller root, so this handler answers GET /tasks. this.ok(body) is a protected helper on ControllerBase that returns a 200 with JSON; ControllerBase lists the rest (created, noContent, notFound, badRequest, redirect, and so on).
These are TC39 stage 3 decorators, the ones now shipping in JavaScript engines, not the older experimentalDecorators design. Nothing here reads reflect-metadata, and no type is recovered at runtime: @Get("") records a path and a method name, and that is all it does. Types for what a route accepts come from the contract you declare in the next step, which is a value the compiler and the pipeline both read.
curl http://localhost:3000/tasks# {"tasks":[]}Step 2: a model() DTO and a contract
Section titled “Step 2: a model() DTO and a contract”A Task has a shape, and so does the body that creates one. Declare both with model() from @flare-ts/lib/schema. A model() is a named, extendable schema token: it parses the same fields schema({...}) would, but you get a class name to share across the contract, the service, and your tests.
Group the per-route descriptors in an httpContract. Each key names a handler method; each value declares what that method accepts (body, route, query) and what it returns (response). At build(), Flare matches each contract key to the handler of the same name and attaches the descriptor to that route. From then on the route parses those fields on the way in, before your handler is called, and this.ctx.extract(entry) hands you the parsed values with their declared types.
import { ControllerBase, FlareHost, httpContract } from "@flare-ts/core";import { Get, Post } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";import { bool, int, model, str } from "@flare-ts/lib/schema";
class CreateTask extends model({ title: str.min(1) }) {}class Task extends model({ id: int, title: str, done: bool }) {}
const TasksContract = httpContract({ list: {}, create: { body: CreateTask, response: { 201: Task } },});
class TasksController extends ControllerBase { public static override deps = []; public static override state = []; public static override contract = TasksContract;
@Get("") list() { return this.ok([]); }
@Post("") create() { const { body } = this.ctx.extract(TasksContract.create); if (!body) return this.badRequest({ error: "Request body is required." }); // body is validated: body.title is a non-empty string, or this never ran. return this.created({ id: 1, title: body.title, done: false }); }}
const host = new FlareHost(node);host.http.controller("/tasks", TasksController);
const app = host.build();app.run();Two things are worth pinning down here. A POST /tasks whose title is empty is rejected with a 400 and field errors before create() runs, so inside the handler body.title is a non-empty string or the handler never ran.
A body contract parses the body it is given, and an empty request has no body to parse: extract().body is null, and the handler still runs. That is why create() opens with if (!body). Declaring a body entry says how a body must look, not that one must be sent, so a route that requires one says so itself.
curl -X POST http://localhost:3000/tasks -d '{"title":"write docs"}'# {"id":1,"title":"write docs","done":false}
curl -X POST http://localhost:3000/tasks -d '{"title":""}'# 400 {"error":"Invalid request body","details":[ ... ]}For the full descriptor surface (route params, query, per-status responses, body size caps) see HTTP contracts, and for model() itself see Models (DTOs).
Step 3: move storage into a service
Section titled “Step 3: move storage into a service”The handler shouldn’t own the data. Put storage in a FlareService so the controller stays about HTTP and the store stays about tasks. A service declares its own dependencies with static deps (here [], it depends on nothing), and the host builds and injects it for you.
This store lives for the whole process, so register it as a singleton with host.singleton(). The controller lists it in static deps and resolves it with this.inject(TaskStore).
import { ControllerBase, FlareHost, FlareService, httpContract,} from "@flare-ts/core";import { Get, Post } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";import { bool, int, model, str } from "@flare-ts/lib/schema";
class CreateTask extends model({ title: str.min(1) }) {}
type TaskRow = { id: number; title: string; done: boolean };
class TaskStore extends FlareService { public static override deps = [];
readonly #rows = new Map<number, TaskRow>(); #nextId = 1;
all(): TaskRow[] { return [...this.#rows.values()]; }
create(title: string): TaskRow { const row: TaskRow = { id: this.#nextId++, title, done: false }; this.#rows.set(row.id, row); return row; }}
const TasksContract = httpContract({ list: {}, create: { body: CreateTask },});
class TasksController extends ControllerBase { public static override deps = [TaskStore]; public static override state = []; public static override contract = TasksContract;
readonly #tasks = this.inject(TaskStore);
@Get("") list() { return this.ok(this.#tasks.all()); }
@Post("") create() { const { body } = this.ctx.extract(TasksContract.create); if (!body) return this.badRequest({ error: "Request body is required." }); return this.created(this.#tasks.create(body.title)); }}
const host = new FlareHost(node);host.singleton(TaskStore);host.http.controller("/tasks", TasksController);
const app = host.build();app.run();Resolve services once as class fields, not inline in each handler: readonly #tasks = this.inject(TaskStore) runs when the controller is constructed for the request, and every method uses the field.
TaskStore is built once at host.build() and every request injects the same instance, so the Map persists across requests. this.inject(Token) only resolves tokens listed in static deps. Omit TaskStore from the array and the call throws, naming the class and the missing token.
curl -X POST http://localhost:3000/tasks -d '{"title":"write docs"}'# {"id":1,"title":"write docs","done":false}curl http://localhost:3000/tasks# [{"id":1,"title":"write docs","done":false}]On Cloudflare Workers there’s no long-lived process for a singleton to live in, so use host.scoped() there and move shared state into a platform binding. See Services and lifetimes and Dependency injection.
Step 4: read, update, and delete one task
Section titled “Step 4: read, update, and delete one task”CRUD needs to address a single task by id. Add a :id route param to the contract with { route: { id: int } }. The pipeline parses the segment as an integer and rejects GET /tasks/banana with a 400 before the handler runs, so route.id is already a number you can look up.
PUT carries a body too. Declare both route and body on the same contract entry; extract() returns both, typed. DELETE needs only the route, so its entry is just the :id param again.
import { ControllerBase, FlareHost, FlareService, httpContract,} from "@flare-ts/core";import { Delete, Get, Put } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";import { bool, int, model, str } from "@flare-ts/lib/schema";
class UpdateTask extends model({ title: str.min(1), done: bool }) {}
type TaskRow = { id: number; title: string; done: boolean };
class TaskStore extends FlareService { public static override deps = []; readonly #rows = new Map<number, TaskRow>();
find(id: number): TaskRow | undefined { return this.#rows.get(id); }
update(id: number, patch: { title: string; done: boolean }): TaskRow | undefined { const row = this.#rows.get(id); if (!row) return undefined; const next = { ...row, ...patch }; this.#rows.set(id, next); return next; }
remove(id: number): boolean { return this.#rows.delete(id); }}
const TasksContract = httpContract({ show: { route: { id: int } }, replace: { route: { id: int }, body: UpdateTask }, destroy: { route: { id: int } },});
class TasksController extends ControllerBase { public static override deps = [TaskStore]; public static override state = []; public static override contract = TasksContract;
readonly #tasks = this.inject(TaskStore);
@Get("/:id") show() { const { route } = this.ctx.extract(TasksContract.show); const row = this.#tasks.find(route.id); return row ? this.ok(row) : this.notFound({ error: "No task with that id." }); }
@Put("/:id") replace() { const { route, body } = this.ctx.extract(TasksContract.replace); if (!body) return this.badRequest({ error: "Request body is required." }); const row = this.#tasks.update(route.id, body); return row ? this.ok(row) : this.notFound({ error: "No task with that id." }); }
@Delete("/:id") destroy() { const { route } = this.ctx.extract(TasksContract.destroy); const removed = this.#tasks.remove(route.id); return removed ? this.noContent() : this.notFound({ error: "No task with that id." }); }}
const host = new FlareHost(node);host.singleton(TaskStore);host.http.controller("/tasks", TasksController);
const app = host.build();app.run();@Get("/:id"), @Put("/:id"), and @Delete("/:id") all register under the /tasks prefix, so they answer GET /tasks/1, PUT /tasks/1, and DELETE /tasks/1. The contract entry name matches the method name (show, replace, destroy), not the HTTP verb.
The delete handler returns this.noContent() on a hit, which is a 204 with no body, and falls back to the same 404 as show when there’s no such id.
Handlers can also throw a registered FlareError instead of returning a status, which moves a shape like this 404 out of the handlers and into one registry. See Typed errors.
Step 5: a setting from config
Section titled “Step 5: a setting from config”Say you want to cap how many tasks the store will hold, and you want that number to be configurable per environment, not hardcoded. That’s what flareConfig is for. Don’t reach for process.env. Declare a typed section, register it with host.cfg(), list it in the service’s static config, and read it with this.config(token).
import { ControllerBase, FlareHost, FlareService, flareConfig, httpContract,} from "@flare-ts/core";import { Post } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";import { defaultTo, int, model, str } from "@flare-ts/lib/schema";
const TasksConfig = flareConfig("tasks", { maxTasks: defaultTo(100, int),});
class CreateTask extends model({ title: str.min(1) }) {}
type TaskRow = { id: number; title: string; done: boolean };
class TaskStore extends FlareService { public static override deps = []; public static override config = [TasksConfig];
readonly #limits = this.config(TasksConfig); readonly #rows = new Map<number, TaskRow>(); #nextId = 1;
atCapacity(): boolean { return this.#rows.size >= this.#limits.maxTasks; }
create(title: string): TaskRow { const row: TaskRow = { id: this.#nextId++, title, done: false }; this.#rows.set(row.id, row); return row; }}
const TasksContract = httpContract({ create: { body: CreateTask },});
class TasksController extends ControllerBase { public static override deps = [TaskStore]; public static override state = []; public static override contract = TasksContract;
readonly #tasks = this.inject(TaskStore);
@Post("") create() { const { body } = this.ctx.extract(TasksContract.create); if (!body) return this.badRequest({ error: "Request body is required." }); if (this.#tasks.atCapacity()) { return this.badRequest({ error: "Task limit reached." }); } return this.created(this.#tasks.create(body.title)); }}
const host = new FlareHost(node);host.cfg(TasksConfig);host.singleton(TaskStore);host.http.controller("/tasks", TasksController);
const app = host.build();app.run();maxTasks defaults to 100 via defaultTo, reads from the tasks section of flare.json, and is overridable per environment with FLARE__tasks__maxTasks=50. The service reads its section once as a field, the same shape as injecting a service. Set the cap to 1 and the second create is refused:
FLARE__tasks__maxTasks=1 npx tsx src/main.ts
curl -X POST http://localhost:3000/tasks -d '{"title":"first"}'# {"id":1,"title":"first","done":false}
curl -i -X POST http://localhost:3000/tasks -d '{"title":"second"}'# HTTP/1.1 400 Bad Request# {"error":"Task limit reached."}See Configuration and Configure your app for the flare.json layout and override precedence.
Step 6: test the whole thing
Section titled “Step 6: test the whole thing”Testing the assembled app means building the same graph a second time, so split the file: one module that registers the app and returns the host, and thin entry points that build it. Your server entry builds and runs; your test builds and drives requests.
import { ControllerBase, FlareHost, FlareService, httpContract,} from "@flare-ts/core";import { Get, Post } from "@flare-ts/core/decorators";import { node } from "@flare-ts/core/node";import { model, str } from "@flare-ts/lib/schema";
class CreateTask extends model({ title: str.min(1) }) {}
type TaskRow = { id: number; title: string; done: boolean };
class TaskStore extends FlareService { public static override deps = []; readonly #rows = new Map<number, TaskRow>(); #nextId = 1;
all(): TaskRow[] { return [...this.#rows.values()]; }
create(title: string): TaskRow { const row: TaskRow = { id: this.#nextId++, title, done: false }; this.#rows.set(row.id, row); return row; }}
const TasksContract = httpContract({ list: {}, create: { body: CreateTask },});
class TasksController extends ControllerBase { public static override deps = [TaskStore]; public static override state = []; public static override contract = TasksContract;
readonly #tasks = this.inject(TaskStore);
@Get("") list() { return this.ok(this.#tasks.all()); }
@Post("") create() { const { body } = this.ctx.extract(TasksContract.create); if (!body) return this.badRequest({ error: "Request body is required." }); return this.created(this.#tasks.create(body.title)); }}
export function createHost() { const host = new FlareHost(node); host.singleton(TaskStore); host.http.controller("/tasks", TasksController); return host;}The entry point does nothing but build and run:
import { createHost } from "./app.js";
createHost().build().run();The test builds the same registrations and drives them through the real pipeline. app.test() returns a handle whose fetch runs routing, the contract, and the controller without binding a socket:
process.env["FLARE_MODE"] = "test";
import { strict as assert } from "node:assert";import { after, before, test } from "node:test";import type { TestAppHandle } from "@flare-ts/core/testing";import { createHost } from "./app.js";
let handle: TestAppHandle;
before(async () => { handle = await createHost().build().test();});
after(async () => { await handle.stop();});
test("creates a task", async () => { const res = await handle.fetch("POST /tasks", { body: { title: "write docs" } }); assert.equal(res.status, 201); assert.deepEqual(await res.json(), { id: 1, title: "write docs", done: false });});
test("lists what was created", async () => { const res = await handle.fetch("GET /tasks"); assert.equal(res.status, 200); assert.deepEqual(await res.json(), [{ id: 1, title: "write docs", done: false }]);});
test("rejects an empty title at the boundary", async () => { const res = await handle.fetch("POST /tasks", { body: { title: "" } }); assert.equal(res.status, 400);});npx tsx --test src/app.test.tsThree things make this work. createHost() registers but doesn’t build, so each entry point decides what to do with the graph. Test mode latches when the host is constructed, which is inside createHost(), so setting FLARE_MODE in the test file is enough (setting it in your runner’s env config works too, and is the usual choice once you have more than one test file). And handle.fetch takes a "METHOD /path" string with an optional body and resolves to a standard Response, so assertions are about status and JSON, not about framework objects.
handle.stop() runs singleton onStop() hooks. These tests share one host, so state carries between them, which is why the second test can list what the first created; for isolation between cases, handle.reset({ replace }) rebuilds scoped state and can swap TaskStore for a stub. See Write your first test, Replace services in tests, and Testing.
What you built
Section titled “What you built”A CRUD tasks service where each piece does one job: the contract parses input at the edge, the controller stays about HTTP, the service owns storage, a config section makes a limit changeable per environment, and a test drives the whole graph through the real pipeline. Swap the Map in TaskStore for a real database client (still a FlareService, still injected) and the controller and contract don’t change.
Where next
Section titled “Where next”- Fundamentals: the model behind what you just used: the arc/host split, what
build()validates, DI lifetimes. - HTTP: middleware, request state, CORS, streaming, and error handlers beyond this tutorial’s surface.
- Testing:
handle.reset({ replace }), unit-testing a controller, and the rest of the harness. - Runtimes: run and shut down on Node, deploy to Workers, Durable Objects.
- HTTP contracts: query parameters, per-status response schemas, and streaming bodies.