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.
| Files | Change |
|---|---|
features/projects/client/queries.ts | Share pagination and mutation refresh rules |
features/projects/components/project-list.tsx | Display projects and create new ones |
features/projects/components/project-detail.tsx | Read, edit, and delete a project |
app/(app)/projects/page.tsx and app/(app)/projects/[id]/page.tsx | Mount both screens in the authenticated layout |
components/app-sidebar.tsx | Add 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- 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.
- Create a project with a description. It appears without a page reload.
- 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.
- 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.
- 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.
- 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.
- 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.