Build your first feature

Build a projects feature with a create form, a paginated list, an edit screen, and ownership rules. Start by generating its API, follow a description field from the contract to the database, then connect the UI and test access.

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.

By the end, you can sign in, open /projects, create a project, edit or delete it, and see the list update. A second account cannot read or change your projects.

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

Give projects an owner

The API responds, but the generated resource has no authorization rules yet. Add them before making projects available to users. Continue editing this resource; do not rerun the generator over your changes.

Organize the files after the API edits before following the next patches:

bunx @biomejs/biome check --write .

The server chooses the owner from the authenticated session. Clients send a name and description; they cannot assign or transfer ownership.

CallerListRead, update, or delete an existing project
Signed out401401
OwnerTheir own projectsAllowed
Another userTheir own projects403

We return 403 for a known project owned by someone else. The response contains no project fields. A missing ID returns 404.

Add userId to the response schema and database table. It is nullable because projects created while trying the API do not have owners yet. Those old rows stay in the database, appear in nobody's list, and fail ownership checks. New projects always get the signed-in user's ID. Assigning legacy rows to accounts would be a separate, deliberate data migration.

Add an optional description to the update body too. Omitting it preserves the stored description; sending an empty string clears its text.

--- a/features/projects/schemas.ts
+++ b/features/projects/schemas.ts
@@ -5,4 +5,5 @@
   name: z.string().min(1),
   description: z.string().nullable(),
+  userId: z.string().nullable(),
   version: z.number().int().min(1),
   createdAt: z.string().datetime(),
@@ -106,4 +107,5 @@
 export const UpdateProjectBodySchema = z.object({
   name: z.string().trim().min(1).max(120),
+  description: z.string().max(500).optional(),
   version: z.number().int().min(1),
 });
--- a/infra/db/schema/projects.ts
+++ b/infra/db/schema/projects.ts
@@ -5,4 +5,5 @@
   name: text("name").notNull(),
   description: text("description"),
+  userId: text("user_id"),
   version: integer("version").notNull(),
   createdAt: text("created_at").notNull(),

Pass ownership through the port

NewProject is an internal repository input. The HTTP create schema still has no userId. The list method requires the user's ID separately from client-supplied filters, so the repository filters by owner before applying pagination.

--- a/features/projects/ports.ts
+++ b/features/projects/ports.ts
@@ -22,7 +22,9 @@
 };

+export type NewProject = CreateProjectInput & { userId: string };
+
 export interface ProjectRepository {
-  list(query: ListProjectsQuery): Promise<ListProjectsResult>;
-  create(input: CreateProjectInput): Promise<Project>;
+  list(query: ListProjectsQuery, userId: string): Promise<ListProjectsResult>;
+  create(input: NewProject): Promise<Project>;
   findById(id: string): Promise<Project | null>;
   update(input: UpdateProjectInput): Promise<Project | null>;

Update both implementations of that port. Keep the generated cursor, sort, and version checks; add ownership to create and list, and description to update.

Update the SQLite and in-memory adapters
--- a/infra/projects/drizzle-project-repository.ts
+++ b/infra/projects/drizzle-project-repository.ts
@@ -19,4 +19,5 @@
     name: row.name,
     description: row.description,
+    userId: row.userId,
     version: row.version,
     createdAt: row.createdAt,
@@ -50,6 +51,9 @@
 }

-function projectListWhere(query: ListProjectsQuery): SQL<unknown> | undefined {
-  const filters: SQL<unknown>[] = [];
+function projectListWhere(
+  query: ListProjectsQuery,
+  userId: string,
+): SQL<unknown> | undefined {
+  const filters: SQL<unknown>[] = [eq(schema.projects.userId, userId)];
   const cursor = projectCursorFilter(query);

@@ -82,6 +86,6 @@
 ): ProjectRepository {
   return {
-    async list(query) {
-      const where = projectListWhere(query);
+    async list(query, userId) {
+      const where = projectListWhere(query, userId);
       const rows = await db
         .select()
@@ -106,4 +110,5 @@
           name: input.name,
           description: input.description ?? null,
+          userId: input.userId,
           version: 1,
           createdAt: now,
@@ -132,4 +137,5 @@
         .set({
           name: input.name,
+          description: input.description,
           version: input.version + 1,
           updatedAt: new Date().toISOString(),
--- a/infra/projects/in-memory-project-repository.ts
+++ b/infra/projects/in-memory-project-repository.ts
@@ -14,4 +14,5 @@
     name: project.name,
     description: project.description,
+    userId: project.userId,
     version: project.version,
     createdAt: project.createdAt,
@@ -72,7 +73,8 @@

   return {
-    async list(query) {
+    async list(query, userId) {
       const name = query.name?.toLocaleLowerCase();
       const allProjects = Array.from(projects.values())
+        .filter((project) => project.userId === userId)
         .filter(
           (project) => !name || project.name.toLocaleLowerCase().includes(name),
@@ -94,4 +96,5 @@
         name: input.name,
         description: input.description ?? null,
+        userId: input.userId,
         version: 1,
         createdAt: now,
@@ -114,4 +117,5 @@
         ...existing,
         name: input.name,
+        description: input.description ?? existing.description,
         version: existing.version + 1,
         updatedAt: new Date().toISOString(),

Define the ownership rule

Create features/projects/policy.ts:

// features/projects/policy.ts
import { definePolicy, deny } from "@beignet/core/ports";
import type { AuthSession } from "@/ports/auth";
import type { Project } from "./schemas";

export const projectPolicy = definePolicy({
  "projects.access": (ctx: { auth: AuthSession | null }, project: Project) => {
    if (ctx.auth && project.userId === ctx.auth.user.id) return true;
    return deny("Only the owner can access this project.");
  },
});

Register the policy in both the gate's type and runtime configuration. Keep the existing todo policy and the gate's onDeny handler, which maps denied decisions to the app's Forbidden catalog error.

--- a/ports/index.ts
+++ b/ports/index.ts
@@ -7,4 +7,5 @@
   UnitOfWorkPort,
 } from "@beignet/core/ports";
+import type { projectPolicy } from "@/features/projects/policy";
 import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
 import type { TodoRepository } from "@/features/todos/ports";
@@ -18,10 +19,13 @@
 };

-export type AppGate = BoundGate<[typeof todoPolicy]>;
+export type AppGate = BoundGate<[typeof todoPolicy, typeof projectPolicy]>;

 export type AppPorts = {
   auth: AuthPort;
   errorReporter: ErrorReporterPort;
-  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
+  gate: GatePort<
+    AuthorizationContext,
+    [typeof todoPolicy, typeof projectPolicy]
+  >;
   idempotency: IdempotencyPort;
   logger: LoggerPort;
--- a/infra/port-wiring.ts
+++ b/infra/port-wiring.ts
@@ -1,4 +1,5 @@
 import { createNoopErrorReporter } from "@beignet/core/error-reporting";
 import { createGate, definePorts } from "@beignet/core/ports";
+import { projectPolicy } from "@/features/projects/policy";
 import { appError } from "@/features/shared/errors";
 import { todoPolicy } from "@/features/todos/policy";
@@ -6,5 +7,5 @@

 const gate = createGate({
-  policies: [todoPolicy],
+  policies: [todoPolicy, projectPolicy],
   onDeny(decision) {
     return appError("Forbidden", {

Enforce it in every use case

Require a session inside all five use cases, so direct calls from scripts or tests have the same requirement as HTTP calls. requireUserId(ctx) throws a framework 401 when there is no session.

Create attaches that ID to the repository input. List passes it to the owner filter. Read, update, and delete load the project and authorize access before returning fields or writing anything. Update checks ownership before reporting version conflicts.

Update the five use cases
--- a/features/projects/use-cases/create-project.ts
+++ b/features/projects/use-cases/create-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { CreateProjectInputSchema, ProjectSchema } from "../schemas";
@@ -8,5 +9,6 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
-    const project = await ctx.ports.projects.create(input);
+    const userId = requireUserId(ctx);
+    const project = await ctx.ports.projects.create({ ...input, userId });

     return project;
--- a/features/projects/use-cases/list-projects.ts
+++ b/features/projects/use-cases/list-projects.ts
@@ -1,4 +1,5 @@
 import "@beignet/core/server-only";
 import { normalizeCursorPage } from "@beignet/core/pagination";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import {
@@ -13,4 +14,5 @@
   .output(ListProjectsOutputSchema)
   .run(async ({ ctx, input }) => {
+    const userId = requireUserId(ctx);
     const page = normalizeCursorPage(input, {
       defaultLimit: 20,
@@ -18,10 +20,13 @@
     });

-    return ctx.ports.projects.list({
-      page,
-      cursor: page.cursor ? decodeProjectCursor(page.cursor) : null,
-      name: input.name,
-      sortBy: input.sortBy,
-      sortDirection: input.sortDirection,
-    });
+    return ctx.ports.projects.list(
+      {
+        page,
+        cursor: page.cursor ? decodeProjectCursor(page.cursor) : null,
+        name: input.name,
+        sortBy: input.sortBy,
+        sortDirection: input.sortDirection,
+      },
+      userId,
+    );
   });
--- a/features/projects/use-cases/get-project.ts
+++ b/features/projects/use-cases/get-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { appError } from "../../shared/errors";
@@ -9,4 +10,5 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
     const project = await ctx.ports.projects.findById(input.id);
     if (!project) {
@@ -16,4 +18,5 @@
     }

+    await ctx.gate.authorize("projects.access", project);
     return project;
   });
--- a/features/projects/use-cases/update-project.ts
+++ b/features/projects/use-cases/update-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { appError } from "../../shared/errors";
@@ -9,4 +10,5 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
     const existing = await ctx.ports.projects.findById(input.id);
     if (!existing) {
@@ -15,4 +17,6 @@
       });
     }
+    await ctx.gate.authorize("projects.access", existing);
+
     if (existing.version !== input.version) {
       throw appError("ProjectConflict", {
--- a/features/projects/use-cases/delete-project.ts
+++ b/features/projects/use-cases/delete-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { z } from "zod";
 import { useCase } from "../../../lib/use-case";
@@ -10,4 +11,10 @@
   .output(z.void())
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
+    const project = await ctx.ports.projects.findById(input.id);
+    if (!project)
+      throw appError("ProjectNotFound", { details: { id: input.id } });
+    await ctx.gate.authorize("projects.access", project);
+
     const deleted = await ctx.ports.projects.delete(input.id);
     if (!deleted) {

Declare the authentication requirement and expected errors on the contract group:

--- a/features/projects/contracts.ts
+++ b/features/projects/contracts.ts
@@ -21,7 +21,11 @@
 });

-const projects = defineContractGroup().namespace("projects").responses({
-  500: ErrorResponseSchema,
-});
+const projects = defineContractGroup()
+  .namespace("projects")
+  .meta({ auth: "required" })
+  .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })
+  .responses({
+    500: ErrorResponseSchema,
+  });

 export const listProjects = projects

The existing route group and server/routes.ts registrations stay in place. The authenticated Next layout protects the screens; the use cases protect the data even when someone calls the API directly.

Verify access before adding UI

The generated CRUD test used anonymous callers. Give its seed rows an owner, provide a signed-in session, and use the real registered gate. Keep its existing CRUD, pagination, conflict, and route assertions.

Update the generated projects test
--- a/features/projects/tests/projects.test.ts
+++ b/features/projects/tests/projects.test.ts
@@ -5 +4,0 @@
-  createTestAnonymousActor,
@@ -7,0 +7 @@
+  createTestUserActor,
@@ -38,0 +39 @@
+        userId: "user_test",
@@ -46,0 +48 @@
+        userId: "user_test",
@@ -54,0 +57 @@
+        userId: "user_test",
@@ -64,0 +68 @@
+        gate: initialPorts.gate,
@@ -66 +70,8 @@
-          getSession: async () => null,
+          getSession: async () => ({
+            user: {
+              id: "user_test",
+              name: "Test User",
+              email: "test@example.com",
+            },
+            session: { id: "session_test" },
+          }),
@@ -82 +93 @@
-      actor: createTestAnonymousActor(),
+      actor: createTestUserActor("user_test"),
@@ -83,0 +95,3 @@
+      auth: await testFixture.ports.auth.getSession(
+        new Request("http://localhost"),
+      ),

Add features/projects/tests/access.test.ts. It runs the access checks against both the in-memory adapter and a temporary SQLite database created from your checked-in migrations. It also calls HTTP routes so a route cannot accidentally skip the use-case rules.

Add the ownership and access tests
// features/projects/tests/access.test.ts
import { createStaticAuth } from "@beignet/core/ports";
import { defineRoutes } from "@beignet/core/server";
import {
  createTestAnonymousActor,
  createTestContextFactory,
  createTestPorts,
  createTestUserActor,
} from "@beignet/core/testing";
import { createInMemoryDevtools } from "@beignet/devtools";
import { createTestApp } from "@beignet/web/testing";
import type { AppContext } from "@/app-context";
import { createTestDatabase } from "@/infra/db/test-database";
import { initialPorts } from "@/infra/port-wiring";
import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository";
import { describe, expect, it } from "@/lib/beignet-test";
import type { AuthRequest, AuthSessionMetadata, AuthUser } from "@/ports/auth";
import { appContext } from "@/server/context";
import {
  createProject,
  deleteProject,
  getProject,
  listProjects,
  updateProject,
} from "../contracts";
import type { ProjectRepository } from "../ports";
import { projectRoutes } from "../routes";
import {
  createProjectUseCase,
  deleteProjectUseCase,
  getProjectUseCase,
  listProjectsUseCase,
  updateProjectUseCase,
} from "../use-cases";

async function userContext(projects: ProjectRepository, userId: string | null) {
  const auth = userId
    ? {
        user: { id: userId, name: userId, email: `${userId}@example.com` },
        session: { id: `session_${userId}` },
      }
    : null;
  const fixture = createTestPorts<AppContext["ports"]>({
    base: initialPorts,
    overrides: {
      projects,
      gate: initialPorts.gate,
      auth: createStaticAuth<AuthUser, AuthSessionMetadata, AuthRequest>(auth),
      devtools: createInMemoryDevtools(),
    },
  });
  return createTestContextFactory<AppContext, AppContext["ports"]>({
    ports: fixture.ports,
    auth,
    actor: userId ? createTestUserActor(userId) : createTestAnonymousActor(),
  })();
}

for (const adapter of ["memory", "sqlite"]) {
  describe(`${adapter} project ownership`, () => {
    it("protects reads and writes through use cases and HTTP", async () => {
      const database =
        adapter === "sqlite" ? await createTestDatabase() : undefined;
      const projects =
        database?.repositories.projects ?? createInMemoryProjectRepository();
      try {
        const alice = await userContext(projects, "alice");
        const bob = await userContext(projects, "bob");
        const anonymous = await userContext(projects, null);
        const project = await createProjectUseCase.run({
          ctx: alice,
          input: { name: "Private", description: "Alice's project" },
        });
        expect(project.userId).toBe("alice");
        expect(
          (await listProjectsUseCase.run({ ctx: alice, input: {} })).items,
        ).toEqual([project]);
        expect(
          (await listProjectsUseCase.run({ ctx: bob, input: {} })).items,
        ).toEqual([]);
        await expect(
          createProjectUseCase.run({
            ctx: anonymous,
            input: { name: "Anonymous" },
          }),
        ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
        await expect(
          listProjectsUseCase.run({ ctx: anonymous, input: {} }),
        ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
        await expect(
          getProjectUseCase.run({ ctx: bob, input: { id: project.id } }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        await expect(
          updateProjectUseCase.run({
            ctx: bob,
            input: { id: project.id, name: "Stolen", version: project.version },
          }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        await expect(
          deleteProjectUseCase.run({ ctx: bob, input: { id: project.id } }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        expect(await projects.findById(project.id)).toEqual(project);
        const saved = await updateProjectUseCase.run({
          ctx: alice,
          input: {
            id: project.id,
            name: "Updated",
            description: "Saved",
            version: project.version,
          },
        });
        expect(saved.description).toBe("Saved");
        await expect(
          updateProjectUseCase.run({
            ctx: alice,
            input: { id: project.id, name: "Stale", version: project.version },
          }),
        ).rejects.toMatchObject({ code: "PROJECT_CONFLICT" });

        for (const ctx of [bob, anonymous]) {
          const app = await createTestApp({
            ports: ctx.ports,
            context: appContext,
            routes: defineRoutes<AppContext>([projectRoutes]),
          });
          try {
            const code = ctx.auth ? "FORBIDDEN" : "UNAUTHORIZED";
            await expect(
              app.request(getProject, { path: { id: project.id } }),
            ).rejects.toMatchObject({ code });
            await expect(
              app.request(updateProject, {
                path: { id: project.id },
                body: { name: "Blocked", version: saved.version },
              }),
            ).rejects.toMatchObject({ code });
            await expect(
              app.request(deleteProject, { path: { id: project.id } }),
            ).rejects.toMatchObject({ code });
            if (!ctx.auth) {
              await expect(
                app.request(listProjects, { query: {} }),
              ).rejects.toMatchObject({ code });
              await expect(
                app.request(createProject, { body: { name: "Blocked" } }),
              ).rejects.toMatchObject({ code });
            }
          } finally {
            await app.stop();
          }
        }
        await deleteProjectUseCase.run({
          ctx: alice,
          input: { id: project.id },
        });
        expect(await projects.findById(project.id)).toBeNull();
      } finally {
        await database?.close();
      }
    });
  });
}

Generate and apply the owner-column migration, then run the tests:

bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db status
bun run test
bun run typecheck

Both adapters should pass. Check that the second user gets an empty list and FORBIDDEN on another user's read, update, and delete; signed-out requests get UNAUTHORIZED. Failed writes must leave the saved project unchanged. A stale owner edit gets PROJECT_CONFLICT.

Connect queries and mutations

Keep pagination defaults and shared refresh rules in features/projects/client/queries.ts. Creating refreshes lists; updating or deleting also refreshes that project's detail query. The detail component can pass rq(getProject).queryOptions(...) directly to useQuery.

// features/projects/client/queries.ts
import { cursorPagination } from "@beignet/react-query";
import { rq } from "@/client";
import {
  createProject,
  deleteProject,
  getProject,
  listProjects,
  updateProject,
} from "../contracts";

export function projectsQueryOptions() {
  return rq(listProjects).infiniteQueryOptions({
    query: { limit: 20 },
    ...cursorPagination(),
  });
}

export function createProjectMutationOptions() {
  return rq(createProject).mutationOptions({
    invalidates: () => [rq(listProjects).contractFilter()],
  });
}

export function updateProjectMutationOptions() {
  return rq(updateProject).mutationOptions({
    invalidates: (_project, variables) => [
      rq(listProjects).contractFilter(),
      rq(getProject).filter({ path: variables.path }),
    ],
  });
}

export function deleteProjectMutationOptions() {
  return rq(deleteProject).mutationOptions({
    invalidates: (_result, variables) => [
      rq(listProjects).contractFilter(),
      rq(getProject).filter({ path: variables.path }),
    ],
  });
}

These are ordinary TanStack Query options. invalidates marks matching queries stale and refetches active queries. Beignet awaits that work before mutateAsync() resolves. Keep lifecycle callbacks inside mutationOptions when adding them; replacing its returned onSuccess would replace the invalidation behavior. See React Query for the full API.

Build the projects list

Create features/projects/components/project-list.tsx. The form uses the create contract's schema, displays validation and request errors, and resets after a successful create. Fields are read-only until the request and cache invalidations finish, so that reset cannot erase edits made during submission. The list includes loading, empty, retry, and load-more states.

Add the list and create form
// features/projects/components/project-list.tsx
"use client";

import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { rhf } from "@/client/forms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  createProjectMutationOptions,
  projectsQueryOptions,
} from "../client/queries";
import { createProject } from "../contracts";

export function ProjectList() {
  const projects = useInfiniteQuery(projectsQueryOptions());
  const create = useMutation(createProjectMutationOptions());
  const form = rhf(createProject).useForm({
    defaultValues: { name: "", description: "" },
  });
  const submit = form.handleSubmit(async (body) => {
    form.clearErrors("root");
    try {
      await create.mutateAsync({ body });
      form.reset();
    } catch (error) {
      form.setError("root", {
        message:
          error instanceof Error ? error.message : "Could not create project.",
      });
    }
  });

  return (
    <div className="space-y-8">
      <h1 className="text-2xl font-semibold">Projects</h1>
      <form onSubmit={submit} className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="project-name">Name</Label>
          <Input
            id="project-name"
            readOnly={form.formState.isSubmitting}
            {...form.register("name")}
          />
          <p role="alert">{form.formState.errors.name?.message}</p>
        </div>
        <div className="space-y-2">
          <Label htmlFor="project-description">Description</Label>
          <Input
            id="project-description"
            readOnly={form.formState.isSubmitting}
            {...form.register("description")}
          />
          <p role="alert">{form.formState.errors.description?.message}</p>
        </div>
        <p role="alert">{form.formState.errors.root?.message}</p>
        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "Creating…" : "Create project"}
        </Button>
      </form>
      <section aria-label="Your projects" className="space-y-4">
        {projects.isPending && <p>Loading projects…</p>}
        {projects.isError && (
          <div role="alert">
            <p>{projects.error.message}</p>
            <Button type="button" onClick={() => void projects.refetch()}>
              Try again
            </Button>
          </div>
        )}
        {projects.data?.pages.every((page) => page.items.length === 0) && (
          <p>No projects yet. Create your first one above.</p>
        )}
        <ul className="space-y-3">
          {projects.data?.pages
            .flatMap((page) => page.items)
            .map((project) => (
              <li key={project.id}>
                <Link className="underline" href={`/projects/${project.id}`}>
                  {project.name}
                </Link>
                <p className="text-sm text-muted-foreground">
                  {project.description}
                </p>
              </li>
            ))}
        </ul>
        {projects.hasNextPage && (
          <Button
            type="button"
            disabled={projects.isFetchingNextPage}
            onClick={() => void projects.fetchNextPage()}
          >
            {projects.isFetchingNextPage ? "Loading…" : "Load more"}
          </Button>
        )}
      </section>
    </div>
  );
}

Expose it under the starter's authenticated layout:

// app/(app)/projects/page.tsx
import { ProjectList } from "@/features/projects/components/project-list";

export default function ProjectsPage() {
  return <ProjectList />;
}

Add the detail and edit screen

Create features/projects/components/project-detail.tsx. The edit form carries the version it loaded and sends that version with the write. A background refetch updates an untouched form when a newer version arrives. Once the user starts editing, preserve the whole draft and its version until they save or reload. Fields are read-only while saving or deleting.

After a successful save, reset the form to the saved response, including its new version. Only accept newer versions from background refreshes, so an older cached response cannot undo that reset if a refresh fails.

If another tab writes first, the server rejects the stale edit. The user can reload the current project and try again. Deletion asks for confirmation and returns to the list after the request and invalidations complete.

Add the detail, edit, and delete UI
// features/projects/components/project-detail.tsx
"use client";

import { useMutation, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { rq } from "@/client";
import { rhf } from "@/client/forms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  deleteProjectMutationOptions,
  updateProjectMutationOptions,
} from "../client/queries";
import { getProject, updateProject } from "../contracts";
import type { Project } from "../schemas";

export function ProjectDetail({ id }: { id: string }) {
  const project = useQuery(rq(getProject).queryOptions({ path: { id } }));
  if (project.isPending) return <p>Loading project…</p>;
  if (!project.data)
    return (
      <div role="alert">
        <p>{project.error?.message ?? "Could not load project."}</p>
        <Button type="button" onClick={() => void project.refetch()}>
          Try again
        </Button>
        <Link href="/projects">Back to projects</Link>
      </div>
    );
  return (
    <div className="space-y-4">
      {project.isError && (
        <p role="alert">
          Could not refresh this project. Your draft is still open.
          {" "}{project.error.message}
        </p>
      )}
      <ProjectEditor key={id} project={project.data} />
    </div>
  );
}

function ProjectEditor({ project }: { project: Project }) {
  const router = useRouter();
  const update = useMutation(updateProjectMutationOptions());
  const remove = useMutation(deleteProjectMutationOptions());
  const form = rhf(updateProject).useForm({
    defaultValues: {
      name: project.name,
      description: project.description ?? "",
      version: project.version,
    },
  });
  const { isDirty, isSubmitting } = form.formState;
  const { getValues, reset } = form;
  const busy = isSubmitting || remove.isPending;
  useEffect(() => {
    if (!isDirty && !busy && project.version > getValues("version")) {
      reset({
        name: project.name,
        description: project.description ?? "",
        version: project.version,
      });
    }
  }, [busy, getValues, isDirty, project, reset]);
  const submit = form.handleSubmit(async (body) => {
    form.clearErrors("root");
    try {
      const saved = await update.mutateAsync({
        path: { id: project.id },
        body,
      });
      form.reset({
        name: saved.name,
        description: saved.description ?? "",
        version: saved.version,
      });
    } catch (error) {
      form.setError("root", {
        message:
          error instanceof Error ? error.message : "Could not save project.",
      });
    }
  });
  async function deleteCurrentProject() {
    if (!window.confirm("Delete this project?")) return;
    try {
      await remove.mutateAsync({ path: { id: project.id } });
      router.push("/projects");
    } catch {
      // The mutation error is rendered below; keep the current page open.
    }
  }

  return (
    <div className="space-y-6">
      <Link href="/projects" className="underline">
        Back to projects
      </Link>
      <h1 className="text-2xl font-semibold">Edit project</h1>
      <form onSubmit={submit} className="space-y-4">
        <input
          type="hidden"
          {...form.register("version", { valueAsNumber: true })}
        />
        <div className="space-y-2">
          <Label htmlFor="project-name">Name</Label>
          <Input
            id="project-name"
            readOnly={busy}
            {...form.register("name")}
          />
          <p role="alert">{form.formState.errors.name?.message}</p>
        </div>
        <div className="space-y-2">
          <Label htmlFor="project-description">Description</Label>
          <Input
            id="project-description"
            readOnly={busy}
            {...form.register("description")}
          />
          <p role="alert">{form.formState.errors.description?.message}</p>
        </div>
        <p role="alert">{form.formState.errors.root?.message}</p>
        <Button type="submit" disabled={busy}>
          {form.formState.isSubmitting ? "Saving…" : "Save project"}
        </Button>
      </form>
      <p className="text-sm text-muted-foreground">
        If another tab changed this project, reload before saving again.
        Reloading discards your unsaved edits.
      </p>
      <Button
        type="button"
        variant="outline"
        disabled={busy}
        onClick={() => window.location.reload()}
      >
        Reload project
      </Button>
      <div>
        <Button
          type="button"
          variant="destructive"
          disabled={busy}
          onClick={() => void deleteCurrentProject()}
        >
          {remove.isPending ? "Deleting…" : "Delete project"}
        </Button>
        {remove.isError && <p role="alert">{remove.error.message}</p>}
      </div>
    </div>
  );
}

Add the dynamic route:

// app/(app)/projects/[id]/page.tsx
import { ProjectDetail } from "@/features/projects/components/project-detail";

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <ProjectDetail id={id} />;
}

Add navigation and clear data on sign-out

Add Projects to the sidebar. Also cancel active queries and clear the Query Client when signing out. The project responses depend on the session cookie, which is deliberately excluded from generated query keys; keeping cached data across account changes could show the previous user's list.

--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -1,4 +1,5 @@
 "use client";

+import { useQueryClient } from "@tanstack/react-query";
 import {
   FileJsonIcon,
@@ -19,4 +20,5 @@
   { href: "/dashboard", label: "Dashboard", icon: LayoutDashboardIcon },
   { href: "/todos", label: "Todos", icon: ListTodoIcon },
+  { href: "/projects", label: "Projects", icon: ListTodoIcon },
   { href: "/settings", label: "Settings", icon: SettingsIcon },
 ];
@@ -36,7 +38,10 @@
   const pathname = usePathname();
   const router = useRouter();
+  const queryClient = useQueryClient();

   async function signOut() {
     await authClient.signOut();
+    await queryClient.cancelQueries();
+    queryClient.clear();
     router.push("/");
     router.refresh();

For other ways of switching accounts, apply the same cache-reset rule at that identity change. Authorization on the server remains required regardless of what is cached in the browser.

Try the complete feature

Run the app checks and start the development server:

bun run format
bun beignet check
bun run dev
  1. Sign in and open Projects. The unowned rows from the earlier API step are hidden. Submit an empty name and confirm that validation prevents it.
  2. Create a project with a description. It appears without a page reload.
  3. Open its detail page, edit the name and description, and save. Return to the list and confirm it shows the new values. Reload the browser to verify persistence.
  4. Open the same project in two tabs and start editing in both. Save the first tab, then submit the second tab's draft. The second save reports a conflict instead of overwriting the first. Use Reload project before editing again. If a tab has no unsaved edits, a background refresh should update its fields and version together.
  5. In a separate browser profile, create a second account. Its project list is empty. Paste the first account's project URL: access is denied. The automated tests verify update and delete are denied too.
  6. Back in the owner's profile, delete the project. You return to the list, where it is gone. Refreshing its old detail URL returns a not-found error.
  7. Sign out and sign in as the other account in the same browser. The first account's projects must not flash from the query cache.

You now have a feature with shared validation, persistence, ownership checks, list and detail screens, mutation invalidation, and tests. Follow Authorization when ownership grows into roles or sharing, and Testing when adding more behavior. Use Workflows when a product requirement introduces background work.

For other features, make resource is the CRUD-shaped generator. Use bun beignet make feature <name> for workflows, or add --dry-run to preview the files a generator would write.