Build your first feature: API

Build a projects feature in three stages, continuing in the same app throughout. Start here by generating its API and following a description field from the shared schema to the database.

StageWorking result
1. Build the API (this page)Create and retrieve persisted projects over HTTP
2. Add ownershipEach account can read and change only its own projects
3. Connect the UICreate, list, edit, and delete projects in the browser

Use the full-stack app from Quickstart with its default SQLite database. Keep the starter's Better Auth setup, authenticated layout, and root Query Client provider. No additional packages are needed.

This first stage is a local API exercise. The generated projects resource has no authorization rules yet; complete Add ownership before making it available to users.

The generator creates the feature's contracts, use cases, ports, and adapters. Your edits in this stage focus on these files:

FilesChange
features/projects/schemas.tsAdd the description to the response and create input
infra/db/schema/projects.tsStore the description
infra/projects/*-project-repository.tsReturn and persist the field in both adapters
features/projects/tests/projects.test.tsUpdate the existing test data
drizzle/Generate and apply the schema migration

Generate the resource

bun beignet make resource projects --useCaseLayout split

This walkthrough selects separate files to develop ownership and concurrency rules across five workflows. Omit --useCaseLayout split to keep a new resource's use cases together in use-cases.ts.

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/port-wiring.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
bun beignet db status

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

Understand the generated code

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/port-wiring.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. These edits use the default SQLite starter. Format and organize the generated files with the app's installed Biome before editing:

bunx @biomejs/biome check --write .

Add the response field and create input in features/projects/schemas.ts:

--- a/features/projects/schemas.ts
+++ b/features/projects/schemas.ts
@@ -4,4 +4,5 @@
   id: z.uuid(),
   name: z.string().min(1),
+  description: z.string().nullable(),
   version: z.number().int().min(1),
   createdAt: z.string().datetime(),
@@ -96,4 +97,5 @@
 export const CreateProjectInputSchema = z.object({
   name: z.string().trim().min(1).max(120),
+  description: z.string().max(500).optional(),
 });

Ask the compiler what else needs the field:

bun run typecheck

The two repository adapters and their test data still return the old shape. The contract and use cases already reference the shared schemas, so they do not need another copy of the field definition.

Add the nullable database column, return it from both adapters, and persist it when a project is created. Omitting the optional input stores null.

--- a/infra/db/schema/projects.ts
+++ b/infra/db/schema/projects.ts
@@ -4,4 +4,5 @@
   id: text("id").primaryKey(),
   name: text("name").notNull(),
+  description: text("description"),
   version: integer("version").notNull(),
   createdAt: text("created_at").notNull(),
--- a/infra/projects/drizzle-project-repository.ts
+++ b/infra/projects/drizzle-project-repository.ts
@@ -18,4 +18,5 @@
     id: row.id,
     name: row.name,
+    description: row.description,
     version: row.version,
     createdAt: row.createdAt,
@@ -104,4 +105,5 @@
           id: crypto.randomUUID(),
           name: input.name,
+          description: input.description ?? null,
           version: 1,
           createdAt: now,
--- a/infra/projects/in-memory-project-repository.ts
+++ b/infra/projects/in-memory-project-repository.ts
@@ -13,4 +13,5 @@
     id: project.id,
     name: project.name,
+    description: project.description,
     version: project.version,
     createdAt: project.createdAt,
@@ -92,4 +93,5 @@
         id: crypto.randomUUID(),
         name: input.name,
+        description: input.description ?? null,
         version: 1,
         createdAt: now,

Give the existing test rows a description too:

--- a/features/projects/tests/projects.test.ts
+++ b/features/projects/tests/projects.test.ts
@@ -36,4 +36,5 @@
         id: "00000000-0000-4000-8000-000000000101",
         name: "Alpha Project",
+        description: null,
         version: 1,
         createdAt: "2024-01-01T00:00:00.000Z",
@@ -43,4 +44,5 @@
         id: "00000000-0000-4000-8000-000000000102",
         name: "Beta Project",
+        description: null,
         version: 1,
         createdAt: "2024-01-02T00:00:00.000Z",
@@ -50,4 +52,5 @@
         id: "00000000-0000-4000-8000-000000000103",
         name: "Gamma Project",
+        description: null,
         version: 1,
         createdAt: "2024-01-03T00:00:00.000Z",

Migrate and verify:

bun beignet db generate
bun beignet db migrate
bun beignet db status
bun run format
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.

Checkpoint: a working API

You can stop here with a projects API backed by SQLite:

Keep this app and its database for the next stage. Continue with 2. Add ownership to restrict each account to its own projects.