Agent Barn’s web app is a Next.js App Router application organized around feature boundaries, runtime-validated API contracts, and tenant-safe server-data caching.
A typical change moves through this path:
App Router page
│
▼
Feature component
│
▼
Query or mutation hook
│
▼
Shared API client
│
▼
Agent Barn Product API- Next.js App Router
- React
- TypeScript
- TanStack Query
- Zod
- Tailwind CSS
- shadcn / Radix
- Zustand
- Playwright
Overview
The current frontend stack also includes React Hook Form, Nuqs, and Sonner. Use ui/package.json and ui/pnpm-lock.yaml as the source of truth for exact dependency versions.
Three rules shape almost every change:
- App Router pages compose features. Feature workflows live under
ui/src/features/. - Important API responses are validated by a feature-local Zod schema, and frontend types are inferred from that schema.
- Server data lives in TanStack Query, and every Organization-scoped query family is isolated across an Organization switch.
Before you begin
You need:
- The
agent-barnrepository - Node.js 24
- Corepack and the repository-pinned pnpm version
- The UI dependencies installed
- A running Product API
- A local Agent Barn user
- An Organization, when developing an Organization-scoped feature
- Docker, when running the complete local stack
Complete local stack
The shortest path. Runs the databases, API, worker, and UI together.
make setup
make runUI-focused development
Run the backend in one terminal:
make db-up
make migrate
make dev-apiThen the frontend in another:
make dev-uimake dev-ui runs the Next.js development server from ui/. The UI is served at http://localhost:3000, and the Product API at http://localhost:8000.
Before changing a feature, read:
- The relevant document under
docs/features/ docs/architecture/ui.mddocs/guidelines/webapp.md- A neighboring feature with similar data and interaction requirements
- Existing shared UI primitives
- Related Playwright tests and data-support helpers
Understand the UI boundaries
The main frontend dependency flow is:
Next.js App Router
│
▼
Feature component
│
▼
Feature query or mutation hook
│
▼
Shared API singleton
│
▼
Same-origin /api request
│
▼
Next.js rewrite
│
▼
Agent Barn Product APIAuthentication, Organization selection, and query caching surround that flow:
NuqsAdapter
└── QueryProvider
└── TooltipProvider
└── AppProvider
├── Public auth route
└── UserContextProvider
└── OrganizationProvider
└── Protected application routeReading the two diagrams together: a request starts at an App Router page, passes through a feature component and its query or mutation hook, reaches the shared API singleton, and leaves as a same-origin /api call that the Next rewrite forwards to the Product API. The provider stack wraps all of it — NuqsAdapter, then QueryProvider, then TooltipProvider, then AppProvider, which branches into public authentication routes on one side and UserContextProvider plus OrganizationProvider on the other.
Source responsibilities
| Location | Responsibility |
|---|---|
ui/src/app/ | App Router pages, layouts, route handlers, and route-level loading and error boundaries |
ui/src/features/<feature>/ | Domain-facing schemas, hooks, components, utilities, providers, and feature state |
ui/src/auth/ | Login, session state, current-user resolution, password flows, and protected-route behavior |
ui/src/shared/ | API transport, error normalization, query-key infrastructure, and root-level shared providers |
ui/src/components/ | Reusable application components and UI primitives |
ui/src/dashboard/ | Shared dashboard composition and shell components |
ui/tests/ | Playwright specs, page objects, API interception support, and fixtures |
Feature shape
A feature normally earns this structure as it grows:
ui/src/features/<feature>/
├── schemas.ts
├── hooks/
│ ├── use-feature-query.ts
│ └── use-feature-actions.ts
├── components/
│ └── feature-panel.tsx
├── utils.ts
└── constants.tsDo not create every optional file for a small feature. Add a boundary when it owns a real responsibility.
Use kebab-case filenames, PascalCase component names, use... hook names, @/* aliases for internal imports, and external imports before aliased and relative ones.
Run the web app
The UI sends browser requests to same-origin paths:
/api/v1/auth/me
/api/v1/organizations/{organization_id}/agentsThe Next.js configuration rewrites /api/:path* to the backend configured by NEXT_PUBLIC_BACKEND_URL, whose local default is http://localhost:8000. You can set it explicitly:
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 make dev-uiThis arrangement lets the browser use same-origin /api URLs while Next.js resolves the backend location. The API singleton uses an empty base URL deliberately:
const apiClient = createApiClient({ baseURL: "" });Verify the development path
- Open
http://localhost:8000/api/v1/health - Confirm the response reports a connected database
- Open
http://localhost:3000/loginand sign in with a local account - Confirm
/dashboardredirects into an available Organization or Platform View - Check the browser network panel for same-origin
/api/v1/...requests
Add a route
App Router pages are composition points. They choose the feature component and supply route parameters, but the feature workflow stays under ui/src/features/.
For an Organization-scoped resource detail page, use a structure such as:
ui/src/
├── app/
│ └── dashboard/
│ └── [orgId]/
│ └── resources/
│ └── [resourceId]/
│ └── page.tsx
└── features/
└── resources/
├── schemas.ts
├── hooks/
├── components/
│ └── resource-detail-page.tsx
└── utils.tsThe route itself should stay small, and remains a Server Component:
import { ResourceDetailPage } from "@/features/resources/components/resource-detail-page";
interface PageProps {
params: Promise<{ resourceId: string }>;
}
export default async function ResourceDetailRoute({ params }: PageProps) {
const { resourceId } = await params;
return <ResourceDetailPage resourceId={resourceId} />;
}The feature component owns the client boundary:
"use client";
import { useResource } from "../hooks/use-resource";
export function ResourceDetailPage({
resourceId,
}: {
resourceId: string;
}) {
const { resource, isLoadingResource, error } = useResource(resourceId);
// Render the feature state.
}Choose the component boundary
Server Components
The default boundary
- Route composition
- Metadata
- Static layout
- Async route parameters
- Components with no browser state or client hooks
Client Components
Add "use client" only when the component owns one of these
- React state or effects
- Event handlers
- TanStack Query hooks
- Zustand or Nuqs hooks
- Browser APIs
- Interactive Radix components
Do not mark an entire route tree as client-rendered because one nested control needs state. Place the client boundary at the smallest useful feature boundary.
Define the API contract
Important responses must be validated with a feature-local Zod schema. The shared client transforms the response before validation, so the schema describes the frontend shape.
Wire format
What the backend sends
{
"id": "7bc68dad-4f87-47fb-996c-636fc367103c",
"display_name": "Example resource",
"organization_id": "1a9650cf-dd44-4ca1-b105-c0cf19f62f56",
"created_at": "2026-08-29T10:00:00Z"
}Frontend format
What the schema validates
import { z } from "zod";
export const ResourceSchema = z.object({
id: z.string().uuid(),
displayName: z.string(),
organizationId: z.string().uuid(),
createdAt: z.string(),
});
export type Resource = z.infer<typeof ResourceSchema>;Export the schema and its inferred type from the same file.
Request transformation
Write ordinary request bodies in frontend camelCase:
await api.patch(
`${orgApiBase}/resources/${resourceId}`,
{
displayName: "Updated resource",
},
);The shared request interceptor sends:
{
"display_name": "Updated resource"
}Do not pre-convert normal JSON request bodies to snake_case.
Query strings
The body transformer does not rewrite URL query-string names. Use the backend’s wire-format parameter names:
const params = new URLSearchParams();
params.set("page", String(page));
params.set("page_size", String(pageSize));
params.set("search", search);Use page_size, not pageSize, in the URL unless the backend endpoint explicitly declares otherwise.
Schema validation
Supply the schema to the shared client:
const response = await api.get<Resource>(
`${orgApiBase}/resources/${resourceId}`,
{
schema: ResourceSchema,
},
);
return response.data;A contract mismatch then becomes an ApiError with a validation failure, instead of silently entering the UI as malformed data.
When an API response changes, update the backend response DTO, the feature Zod schema, query and mutation hooks, fixtures, intercepted API responses, Playwright expectations, and every component that renders the changed fields.
Query server data
Server-owned application data belongs in TanStack Query. Three pieces work together:
Query key
Built from the centralized factory in ui/src/shared/query-keys.ts, extended by feature-local helpers. Never a literal array scattered through components.
Feature hook
Owns the key, the enabled guard, the shared API call, and the response schema. Exposes domain-oriented names instead of a raw query observer.
Feature component
Consumes the hook and owns the loading, empty, and error presentation for the data it requested.
1. Define the query keys
import { createQueryKeyStructure } from "@/shared/query-keys";
export const resourcesKey = createQueryKeyStructure("resources");The factory provides list and detail families:
resourcesKey.all
resourcesKey.lists()
resourcesKey.list({ filters })
resourcesKey.details()
resourcesKey.detail(resourceId)Extend those helpers for nested resources:
export const resourceHistoryKey = (resourceId: string) =>
[...resourcesKey.detail(resourceId), "history"] as const;Do not scatter literal arrays such as ["resources", "detail", id] through components.
2. Add an Organization-scoped hook
"use client";
import { useQuery } from "@tanstack/react-query";
import { useOrganizationApiBase } from "@/features/organizations/hooks/use-organization-api-base";
import { useOrganizationContext } from "@/features/organizations/providers/organization-provider";
import { api } from "@/shared/api";
import { ResourceSchema, type Resource } from "../schemas";
import { resourcesKey } from "../utils";
export function useResource(resourceId: string | null) {
const orgApiBase = useOrganizationApiBase();
const { selectedOrganization } = useOrganizationContext();
const organizationId = selectedOrganization?.id ?? null;
const query = useQuery({
queryKey: resourcesKey.detail(
organizationId && resourceId
? `${organizationId}:${resourceId}`
: "none",
),
enabled: organizationId !== null && resourceId !== null,
queryFn: async () => {
const response = await api.get<Resource>(
`${orgApiBase}/resources/${resourceId}`,
{
schema: ResourceSchema,
},
);
return response.data;
},
});
return {
resource: query.data ?? null,
isLoadingResource: query.isPending,
error: query.error,
refetchResource: query.refetch,
};
}Hooks should expose domain-oriented names. Components should not need to understand every internal property of a raw query observer.
3. Decide how Organization identity enters the cache
Including Organization identity directly in the key makes the separation explicit, but you must still make sure components never display a previous Organization’s cached result as placeholder data.
4. Use progressive queries for load-more interfaces
const query = useInfiniteQuery({
queryKey: resourcesKey.list({
scope: { organizationId, mode: "infinite" },
filters: { search, pageSize },
}),
queryFn: async ({ pageParam = 1 }) => {
// Fetch one page.
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1;
const totalPages = Math.ceil(lastPage.total / lastPage.pageSize);
return nextPage <= totalPages ? nextPage : undefined;
},
});Keep follow-up loading local. Loading another page should not replace the entire route with its initial loading fallback.
Mutate server data
Mutations own user-triggered server workflows. The normal lifecycle is:
- Submit A user action in an event handler starts the workflow — never an effect watching state.
- Validate Client-side validation runs first, usually a Zod form schema through React Hook Form.
- Mutate The TanStack mutation calls the shared API client, which transforms the body and validates the response.
- Update or invalidate Set the detail cache directly where the response is authoritative, then invalidate every affected family.
- Notify Report success or failure concisely, normally through Sonner.
- Render new state The refreshed cache re-renders the affected views without a manual refetch chain.
A representative update hook is:
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useOrganizationApiBase } from "@/features/organizations/hooks/use-organization-api-base";
import { api } from "@/shared/api";
import { ResourceSchema, type Resource } from "../schemas";
import { resourcesKey } from "../utils";
type ResourceUpdate = {
resourceId: string;
displayName?: string;
};
export function useUpdateResource() {
const queryClient = useQueryClient();
const orgApiBase = useOrganizationApiBase();
return useMutation({
mutationFn: async ({ resourceId, ...body }: ResourceUpdate) => {
const response = await api.patch<Resource>(
`${orgApiBase}/resources/${resourceId}`,
body,
{
schema: ResourceSchema,
},
);
return response.data;
},
onSuccess: (resource) => {
queryClient.setQueryData(
resourcesKey.detail(resource.id),
resource,
);
void queryClient.invalidateQueries({
queryKey: resourcesKey.lists(),
});
},
});
}Adapt the cache key to the Organization strategy the feature selected.
Cache behavior by mutation
| Mutation | Cache behavior |
|---|---|
| Create | Invalidate affected collection families |
| Update | Update or invalidate the detail, and invalidate affected lists |
| Delete | Remove the detail, and invalidate affected lists and summaries |
| Publish or restore | Invalidate the current detail, version history, and relevant collections |
| Lifecycle action | Refresh detail, lists, health, and other derived state affected by the transition |
Do not invalidate only the component currently on screen. Consider every list, detail, summary, selector, and dashboard that represents the changed entity.
Preserve authentication and Organization scope
Protected application routes are composed through:
AppProvider
└── UserContextProvider
└── OrganizationProvider
└── Dashboard routeAuthentication
The shared API client sends credentials, reads access tokens from the authentication store, attaches the bearer header, refreshes near-expiry tokens, coalesces simultaneous refresh attempts, retries one failed authenticated request after refresh, marks the session expired when refresh fails, and normalizes failures as ApiError.
Bypassing the shared client also bypasses token refresh, key transformation, schema validation, and normalized errors. Use skipAuth: true only for deliberately public endpoints — login, forgot password, reset password, invite acceptance, and best-effort logout cleanup.
Public authentication routes bypass UserContextProvider and OrganizationProvider. Protected routes resolve the current user before rendering their children.
Organization View
The active Organization comes from the URL, /dashboard/{orgId}/.... Use useOrganizationApiBase() to build Organization-scoped API paths:
const orgApiBase = useOrganizationApiBase();
const url = `${orgApiBase}/agents`;That produces /api/v1/organizations/{organization_id}/agents.
Do not mutate a shared Organization header. The Organization is explicit in both the dashboard URL and the API URL. If the URL contains an inaccessible or stale Organization ID, OrganizationProvider redirects to an available fallback.
Platform View
Platform View lives under /dashboard/platform and has no active Organization. Platform hooks call /api/v1/platform/... directly and must not use useOrganizationApiBase().
Protect platform interfaces with the existing Platform Administrator UI boundary, but remember that the API remains the final authorization authority.
Organization switching and query isolation
Several existing query families do not encode Organization identity in their keys. On a genuine Organization-to-Organization switch, OrganizationProvider removes those known families before child query effects start.
Build feature UI
Build new interfaces from existing primitives and design tokens before introducing another abstraction.
State ownership
| State | Owner |
|---|---|
| API data | TanStack Query |
| Form input | React Hook Form or local form state |
| Open dialog or selected row | Local component state |
| Shareable tab, search, or filter | URL state through Nuqs, when appropriate |
| Current user | Authentication context and query |
| Active Organization | Organization provider and the URL |
| Remembered Organization fallback | The existing Organization Zustand store |
| Theme or cross-feature client preference | An existing provider, or an explicitly justified store |
Use this, avoid this
| Topic | Use this | Avoid this |
|---|---|---|
| Confirmation | The shared ConfirmationDialog for deletion, publishing, discarding a draft, restoring a version, lifecycle changes, replacing access, and leaving with unsaved changes | window.confirm(), window.alert(), or alert() |
| Option selection | The shadcn Select primitives, with every SelectItem inside a SelectGroup | A native <select> control for user-facing option selection |
| Server data | TanStack Query as the single owner of freshness and invalidation | Copying API responses into Zustand to make them globally reachable |
| Orchestration | Event handlers, mutationFn, onSuccess, onError, or a domain-oriented action hook | useEffect chains that watch mutation state and start the next workflow |
| Styling | Tailwind utilities, the cn() helper, Agent Barn CSS variables, and the existing af-* classes | Arbitrary page widths that shift dashboard content between routes |
| Transport | The shared api singleton from @/shared/api | An ad hoc fetch wrapper or a second Axios instance for normal API calls |
Forms
For forms with meaningful validation: define a Zod form schema, infer the TypeScript form type, use React Hook Form with the Zod resolver, render accessible labels and field errors, submit through a mutation hook, disable duplicate submission while pending, and show concise success or failure feedback.
Confirmations
Use the shared ConfirmationDialog for every confirmation workflow:
<ConfirmationDialog
open={isConfirmingDelete}
onOpenChange={setIsConfirmingDelete}
title="Delete this resource?"
description="This action cannot be undone."
confirmLabel="Delete resource"
pendingLabel="Deleting…"
variant="destructive"
isPending={deleteMutation.isPending}
onConfirm={handleDelete}
/>Option selectors
Use the shared shadcn Select primitives, keeping every SelectItem inside a SelectGroup:
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Choose an option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="first">First option</SelectItem>
<SelectItem value="second">Second option</SelectItem>
</SelectGroup>
</SelectContent>
</Select>Do not add a native <select> for user-facing option selection.
Styling
Use Tailwind utilities, the existing cn() helper, shadcn and Radix primitives, Lucide or existing Agent Barn icons, and Sonner for transient notifications. Reuse the semantic variables:
var(--bg)
var(--bg-elev)
var(--bg-soft)
var(--line)
var(--line-strong)
var(--ink)
var(--ink-3)
var(--accent-ink)
var(--ok)
var(--warn)
var(--err)And the existing utility classes:
af-page
af-card
af-card-hover
af-btn
af-btn-primary
af-btn-danger
af-inputaf-page owns dashboard page width and padding. Do not introduce arbitrary page widths that cause dashboard content to shift between routes.
Accessibility
Use semantic elements, real buttons for actions, labels connected to inputs, accessible dialog titles and descriptions, keyboard-operable controls, visible focus styles, meaningful empty and error states, text in addition to color for status, and accessible names that Playwright can select reliably.
Handle loading and errors
Choose the boundary according to who owns the asynchronous work.
| Situation | Boundary |
|---|---|
| The router owns an initial page-blocking wait | Route-segment loading.tsx |
| The route cannot render after an initial failure | Route-segment error.tsx |
| A feature component owns its initial query | Local skeleton or loading state |
| A rendered component fails to load data | Inline error with retry |
| Search, pagination, or refetch is pending | Local follow-up loading state |
| One subtree is waiting | Local Suspense boundary |
| User or Organization context is hydrating | Provider-owned fallback |
A TanStack Query hook error does not automatically reach an App Router error.tsx. If the component owns the query, it also owns the inline error and retry behavior.
Use the shared error UI where it fits:
<AppErrorState
error={error}
title="We couldn't load the resources"
description="The resource list is unavailable right now."
onRetry={() => {
void refetch();
}}
retryLabel="Retry"
/>ApiError statuses
| Status | Suggested UI treatment |
|---|---|
0 | Network error with retry |
401 | Session-expiration flow |
403 | Access denied — explain that permission is required |
404 | Not-found or inaccessible state |
409 | Explain the conflicting business or lifecycle state |
422 | Show actionable form or validation feedback |
500+ | Server error with retry when safe |
Zod validation failure | Report a contract mismatch rather than rendering malformed data |
Keep page content mounted during follow-up refetches where possible. A search or load-more operation should not replace a usable page with its first-load skeleton.
Work with streaming
Browser streaming fetch
│
▼
Next.js route handler
/api/v1/organizations/{organizationId}/agents/{agentId}/logs/stream
│
▼
Backend SSE endpoint
│
▼
Agent log streamThe Next route handler runs in the Node.js runtime, forwards the bearer header, resolves the internal backend hostname server-side, preserves text/event-stream, disables response buffering and transformation, and forwards the upstream body as a stream.
The browser hook uses streaming fetch, supplies the bearer access token, reads the response through TextDecoderStream, parses SSE data: lines, aborts on unmount or disable, and reconnects with bounded exponential backoff.
Do not generalize this exception into another API client. For ordinary JSON requests, keep using the shared api singleton.
If another streaming feature is required, define its authentication behavior, proxy boundary, cancellation behavior, event framing, reconnect policy, error state, cache or local-state ownership, and browser test strategy.
Test the feature
UI behavior is verified with Playwright. Test responsibilities are divided across four locations:
ui/tests/e2e/ Specs Describe user-visible behavior, arrange API state through shared data support, act through page objects, and hold every assertion.
ui/tests/pages/ Page objects Expose user-level actions and centralize stable selectors. They contain no assertions.
ui/tests/pages/data-support/ Data support Owns API interception, keeping route matching precise enough not to catch neighboring endpoints.
ui/tests/fixtures/ Fixtures Own reusable static response data in the backend wire format.
ui/tests/e2e/
Specs describe behavior and contain assertions
│
▼
ui/tests/pages/
Page objects own selectors and user actions
│
▼
ui/tests/pages/data-support/
Data support owns API interception
│
▼
ui/tests/fixtures/
Fixtures own reusable static response dataSelector priority
- Accessible role and name
- Label or stable visible text
- An existing test ID, when semantic selection is insufficient
Data support and fixtures
Keep request interception out of specs when a reusable feature support helper can own it. Mock responses must represent the backend wire format — the shared response interceptor then converts their snake_case fields to the frontend’s camelCase schemas. When inspecting a request body at the network boundary, expect the shared client to have already converted frontend fields to snake_case.
Keep endpoint matching precise. A broad wildcard can accidentally intercept list, detail, draft, version, and mutation endpoints with one response.
When an API schema changes, update fixture fields, response interception, request-body expectations, the feature Zod schema, affected page objects, and spec assertions together.
Run Playwright
From the repository root:
make test-uiFor headed or interactive debugging:
cd ui
pnpm test:watch
pnpm test:ui
pnpm test:debugThe local Playwright server runs on port 3003 and uses a separate .next-e2e output directory, so it does not collide with a normal development server. Playwright retains traces on failure and captures failure screenshots.
Verify the change
Run the standard UI verification from the repository root:
make lint-ui
make check-ui
make test-uiThese cover ESLint and Next.js rules, TypeScript type checking, and Playwright browser behavior.
When the change touches routing, a Next route handler, rewrites, environment-dependent behavior, or production composition, also run:
cd ui
pnpm buildWhen the feature changes an API contract, also run the relevant backend checks:
make check-api
make test-apiBefore handing off the change, confirm:
- Route files only compose feature behavior
- Client boundaries are no broader than necessary
- Important responses are validated by Zod
- Frontend types are inferred from schemas
- Ordinary calls use the shared API client
- Query-string fields use backend names
- Query keys are centralized
- Organization identity or switch eviction protects cached data
- Queries have
enabledguards where required - Mutations update or invalidate all affected query families
- Authentication and provider order remain intact
- Platform View does not assume an active Organization
- Loading and error ownership matches the asynchronous work
- Confirmations use
ConfirmationDialog - Option selectors use the shared shadcn Select
- Server data remains in TanStack Query
- Playwright selectors and interception are reusable
- Lint, type checking, and relevant browser tests pass
Troubleshooting
| Issue | Likely cause | What to check |
|---|---|---|
| Zod reports a missing camelCase field | The backend response or mock does not match the expected wire contract | Inspect the network response, the response transformation, and the feature schema |
| The API receives an unknown query parameter | A frontend query string used pageSize instead of page_size | Use backend wire names in URLSearchParams |
| A request carries no bearer token | The feature bypassed the shared client, or used skipAuth incorrectly | Import api from @/shared/api and inspect the auth interceptor |
| The app repeatedly refreshes or logs out | Refresh handling was bypassed, or a public request was treated as protected | Check the shared auth flow, the token response schema, and skipAuth usage |
| Data from the previous Organization flashes after switching | The query key lacks Organization identity and is absent from switch eviction | Dimension the key, or add the family to the Organization-scoped eviction set |
| An Organization-scoped hook fails in Platform View | It called useOrganizationApiBase() with no active Organization | Use a platform endpoint and a platform-specific hook |
| A query runs before an ID exists | The query lacks an enabled guard | Disable it until all required IDs and context are available |
| A successful mutation leaves stale UI | The mutation invalidated the wrong key, or only one representation | Invalidate affected list, detail, summary, and nested families |
| An App Router error page never appears | The error belongs to a client query, not the router | Render an inline component error with retry |
| The whole page flashes during pagination | Follow-up loading reuses the initial route or page skeleton | Keep the existing content and show local fetch-next-page feedback |
| A confirmation blocks Playwright or browser automation | A native confirmation API was introduced | Replace it with ConfirmationDialog |
| A selector is flaky | The test depends on DOM structure or styling classes | Prefer accessible roles, labels, or stable visible names |
| A Playwright mock handles the wrong endpoint | The route wildcard is too broad | Match method, path, and query behavior more precisely |
| An intercepted response fails only in the UI | The mock used frontend camelCase instead of the backend wire format | Return backend snake_case and let the API client transform it |
| Log streaming connects but no lines appear | The stream was routed through buffered request handling | Check the dedicated Next route handler and the SSE response headers |
| Development works but the production build fails | A server/client boundary, environment variable, or route handler is invalid at build time | Run pnpm build and inspect the first compile or prerender error |
| UI types pass but the real API fails | Browser tests mock a contract the backend does not implement | Run backend integration tests and compare OpenAPI with the Zod schema |