The shape of the codebase
The web app uses a feature-based architecture: code is grouped by what it does for the product, not by what kind of file it is. A feature owns its components, its API calls, its state, and its types.shared/ on the second use.
Features
Each feature can use up to eight folders. Not every feature needs all of them. Use what the feature actually requires.
What the four current features actually use:
There are no
index.ts barrel files. Imports always point at the concrete file, which keeps
module graphs explicit and avoids accidental circular imports.The data layer
This is the part most worth understanding before you write code. Every API interaction flows through the same four layers:fetch. Services never call useQuery.
1. The API client
src/shared/lib/api-client.ts is the wrapper every feature calls through. It exports:
Every request sends
credentials: "include". That’s how the session cookie travels. Passing
json sets Content-Type automatically; pass body directly for FormData uploads.
2. Services
A service is a plain async function. No React, no caching, no error handling beyond whatApiError gives you.
context: ApiRequestContext argument carries { headers?, signal? }. It’s what
lets queries pass an abort signal and lets server components forward cookies.
3. Query keys
Features that fetch data have a*.keys.ts file exporting hierarchical key factories. This is
what makes targeted cache invalidation possible.
projectKeys.lists() refreshes every list variant without
touching cached detail pages.
4. Queries and mutations
Reads go in*.queries.ts, writes in *.mutations.ts.
{ signal } being forwarded into the service. TanStack Query aborts in-flight requests
when a component unmounts, and every read service accepts it.
Mutations use a key from *MutationKeys and invalidate what they changed:
Server state vs client state
TanStack Query: server state
Anything that came from the API. Cached, deduplicated, invalidated. This is the vast
majority of state in the app.
Zustand: client state
State the server doesn’t own. In practice: one store, the project-creation wizard.
Query client configuration
src/shared/lib/query-client.ts builds the client: staleTime 5 minutes, gcTime 30 minutes,
retry: 2, and refetchOnWindowFocus: false. shouldDehydrateQuery is widened to include
pending queries, which is what makes streaming work.
src/app/providers.tsx wraps the tree in QueryClientProvider and
ReactQueryStreamedHydration.
The app uses streaming hydration only. There is no
HydrationBoundary, no
prefetchQuery, and no useSuspenseQuery anywhere in the codebase. Server components render
client views that call useQuery, and results stream in. Follow that pattern rather than
introducing a second one.The one Zustand store
src/features/projects/stores/project-create.store.ts holds the multi-step project creation
form: the chosen method, form fields, selected repository, and the current step. It’s wrapped
in devtools(persist(...)), persisted under project-create-storage, and partialized to
{ formData, currentStep } so a refresh doesn’t lose progress.
Server-side data fetching
Some routes fetch on the server:generateMetadata, the OG image routes, and sitemap.ts.
For authenticated server-side reads, cookies must be forwarded explicitly:
INTERNAL_SERVER_API_URL, which is what lets
the app talk to the API over an internal network in Docker.
Authentication
better-auth runs on the API, not here. This repository contains only the client:src/shared/lib/auth-client.ts:createAuthClient()pointed atNEXT_PUBLIC_API_URLsrc/features/auth/services/auth.service.ts: sign in, link/unlink a provider, sign out, delete accountgetCurrentUser()(hittingGET /users/me) is the app’s real source of truth for who’s signed in. It returnsnullon a 401. TheuseCurrentUserQuery()hook wraps it under the key["user", "me"].
Route protection
src/middleware.ts guards /profile/me, /projects/create, /projects/:id/edit,
/onboarding, and /dashboard. It checks only for the presence of a better-auth session
cookie. No token validation, no API call. Unauthenticated visitors are sent to
/auth/login?redirectTo=….
Cookie-presence checking is why the mock API can exercise protected
routes with no OAuth at all: it sets the same cookie name.
<OnboardingRedirect /> in providers.tsx pushes signed-in users with an incomplete
profile to /onboarding.
File naming
Files are kebab-case with a suffix that says what they are:Styling
Tailwind v4, configured entirely in CSS atsrc/app/globals.css:
@theme inline { … }maps design tokens to utility classes:root { … }holds the raw values, including the brand ramp--ost-blue-one→--ost-blue-fourand semantic tokens like--destructiveand--success@layer basesets border, ring, selection, and heading defaults
tailwind.config.js. Adding one has no effect.
Compose classes with cn() from src/shared/lib/utils.ts (clsx + tailwind-merge). Biome
auto-sorts classes inside cn, clsx, and cva.
Shared UI lives in src/shared/components/:
Next: features & routes
What each feature contains, and every URL in the app.