Build your first feature: UI

Stage 3 of 3. Continue in the app from 2. Add ownership, with its migrations applied and access tests passing. Keep the starter's authenticated layout, Query Client provider, and form helpers.

Connect that API to a create form, paginated list, and detail/edit screen. By the end, you can use the feature at /projects, recover from conflicting edits, and switch accounts without retaining another user's cached data. No additional packages are needed.

FilesChange
features/projects/client/queries.tsShare pagination and mutation refresh rules
features/projects/components/project-list.tsxDisplay projects and create new ones
features/projects/components/project-detail.tsxRead, edit, and delete a project
app/(app)/projects/page.tsx and app/(app)/projects/[id]/page.tsxMount both screens in the authenticated layout
components/app-sidebar.tsxAdd navigation and clear cached data on sign-out

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.