Build your first feature
The starter ships with todos. This page generates a second feature, reads the code it creates, then makes one real change and follows it from the contract to the database. Run it inside a starter app from Quickstart.
Generate the resource
bun beignet make resource projectsThe generator writes a compiling vertical slice: a contract group with list,
create, read, update, and delete endpoints, shared schemas, five use cases, a
repository port, an in-memory adapter for tests, a Drizzle table and adapter,
a feature route group, and a starter test. It also registers the new pieces in
ports/index.ts, infra/app-ports.ts, infra/db/repositories.ts, and
server/routes.ts.
Because the starter persists with Drizzle, create and apply the migration for the new table:
bun beignet db generate
bun beignet db migrateThen confirm the routes are wired:
bun beignet routesMETHOD PATH CONTRACT HANDLER
GET /api/projects listProjects app/api/[[...path]]/route.ts:GET
POST /api/projects createProject app/api/[[...path]]/route.ts:POST
DELETE /api/projects/:id deleteProject app/api/[[...path]]/route.ts:DELETE
GET /api/projects/:id getProject app/api/[[...path]]/route.ts:GET
PATCH /api/projects/:id updateProject app/api/[[...path]]/route.ts:PATCH
...Read the contract
features/projects/contracts.ts owns the HTTP surface. Each endpoint is a
builder chain that names its inputs, catalog errors, and responses:
// features/projects/contracts.ts (excerpt)
const projects = defineContractGroup()
.namespace("projects")
.responses({ 500: ErrorResponseSchema });
export const createProject = projects
.post("/api/projects")
.body(CreateProjectInputSchema)
.responses({ 201: ProjectSchema });
export const getProject = projects
.get("/api/projects/:id")
.pathParams(ProjectIdInputSchema)
.errors({ ProjectNotFound: errors.ProjectNotFound })
.responses({ 200: ProjectSchema });The schemas it references live in features/projects/schemas.ts, so use
cases, ports, tests, and the client can share them without importing the
contract.
Read the use case
Each endpoint binds to a use case in features/projects/use-cases/. The
generated get-project.ts is the whole pattern in one file:
// features/projects/use-cases/get-project.ts (excerpt)
export const getProjectUseCase = useCase
.query("projects.get")
.input(ProjectIdInputSchema)
.output(ProjectSchema)
.run(async ({ ctx, input }) => {
const project = await ctx.ports.projects.findById(input.id);
if (!project) {
throw appError("ProjectNotFound", {
details: { id: input.id },
});
}
return project;
});Input and output are validated against the same schemas the contract uses.
The use case throws a catalog error the contract declared, and it reaches
persistence only through ctx.ports — never through a database import.
Read the port
features/projects/ports.ts is the dependency interface the use cases
depend on:
// features/projects/ports.ts (excerpt)
export interface ProjectRepository {
list(query: ListProjectsQuery): Promise<ListProjectsResult>;
create(input: CreateProjectInput): Promise<Project>;
findById(id: string): Promise<Project | null>;
update(input: UpdateProjectInput): Promise<Project | null>;
delete(id: string): Promise<boolean>;
}Two adapters implement it: infra/projects/drizzle-project-repository.ts for
the real database and infra/projects/in-memory-project-repository.ts for
tests. infra/app-ports.ts wires the Drizzle one into ctx.ports.projects.
features/projects/routes.ts then maps each contract to its use case, and the
generator registered that route group in server/routes.ts for you.
Make a change: add a field
Projects need a description. Add it where the shape is defined,
features/projects/schemas.ts:
export const ProjectSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
+ description: z.string().nullable(),
version: z.number().int().min(1),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export const CreateProjectInputSchema = z.object({
name: z.string().min(1).max(120),
+ description: z.string().max(500).optional(),
});Now ask the compiler what else has to learn about the field:
bun run typecheckinfra/projects/drizzle-project-repository.ts(17,2): error TS2741:
Property 'description' is missing ...
infra/projects/in-memory-project-repository.ts(12,2): error TS2741:
Property 'description' is missing ...
features/projects/tests/projects.test.ts(54,52): error TS2345: ...This is the payoff of the structure: the contract, use cases, route group, and typed client all updated themselves through inference. Only the adapters behind the port — and the test data — still describe the old shape.
Fix the database table in infra/db/schema/projects.ts:
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
+ description: text("description"),
version: integer("version").notNull(),Then teach both repository adapters the field. In each create, persist
description: input.description ?? null, and in each toProject mapper,
return description alongside the other columns. Finally add
description: null to the seed rows in
features/projects/tests/projects.test.ts.
Migrate and verify:
bun beignet db generate
bun beignet db migrate
bun run test
bun run lint
bun run typecheckSee it respond
With bun run dev running:
curl -s -X POST http://localhost:3000/api/projects \
-H "content-type: application/json" \
-d '{"name":"Docs rewrite","description":"Shipped from the tutorial"}'{
"id": "7a8569a5-1248-4c49-b109-20488bd77171",
"name": "Docs rewrite",
"description": "Shipped from the tutorial",
"version": 1,
"createdAt": "2026-06-11T23:29:58.517Z",
"updatedAt": "2026-06-11T23:29:58.517Z"
}List endpoints filter too: curl -s "http://localhost:3000/api/projects?name=Docs"
returns the project inside a cursor-paged envelope.
The generated resource has no authorization rules yet — any caller can reach
it. Give it rules in features/projects/policy.ts and the use cases before
shipping; see Authorization.
Where to go next
make resource is the CRUD-shaped generator used here; use
bun beignet make feature <name> when the concept is a workflow rather than
a resource, and add --dry-run to either to preview the write plan first.
Build UI for the feature under features/projects/components/ with the typed
client helpers — the todos feature in the starter is the working example.
After any manual edits, bun beignet lint and bun beignet doctor confirm
the app still matches its conventions.