Skip to main content

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.
The rule of thumb: start inside a feature. Move to 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:
Components never call 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.
apiData throws Invalid API response for <path>: missing data if the payload has no data key. If you’re adding a mock handler, it must return the real envelope shape. See the API response format.

2. Services

A service is a plain async function. No React, no caching, no error handling beyond what ApiError gives you.
The trailing 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.
Because keys nest, invalidating projectKeys.lists() refreshes every list variant without touching cached detail pages.
Never write a query key inline. queryKey: ["projects", id] bypasses the factory and won’t be invalidated by mutations that target projectKeys.detail(id).

4. Queries and mutations

Reads go in *.queries.ts, writes in *.mutations.ts.
Note { 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.
Never mirror API data into Zustand. It’s the fastest way to ship a stale-data bug.

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:
Server-side requests resolve their base URL from 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 at NEXT_PUBLIC_API_URL
  • src/features/auth/services/auth.service.ts: sign in, link/unlink a provider, sign out, delete account
  • getCurrentUser() (hitting GET /users/me) is the app’s real source of truth for who’s signed in. It returns null on a 401. The useCurrentUserQuery() 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.
A global <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:
Known deviations. The codebase isn’t uniform, and you’ll notice:
  • Some feature hooks skip the suffix (use-project-bookmark.ts). src/shared/hooks/ is consistent; feature hooks aren’t.
  • src/shared/components/ui/ is mostly bare kebab-case (button.tsx) because shadcn generates it that way. Only a few files use .component.tsx.
  • One file is PascalCase: src/shared/components/shared/ProjectCard.tsx.
Follow the table for new files. Don’t rename existing ones as a drive-by. It makes reviews harder for no functional gain.

Styling

Tailwind v4, configured entirely in CSS at src/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-four and semantic tokens like --destructive and --success
  • @layer base sets border, ring, selection, and heading defaults
There is no 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.