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 projects

The 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 migrate

Then confirm the routes are wired:

bun beignet routes
METHOD  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 typecheck
infra/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 typecheck

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