Workbench

Project structure

A src/ layout where shared code and feature code never mix, and every magic string has one home.

The layout

Everything lives in src/. Content that isn't code, like blog posts, stays at the repo root.

I don't add new root folders unless there's a real reason. A new folder is a new place to look.

Feature folders

Business logic belongs to a feature. If only the auth feature uses a component, hook or schema, it lives in features/auth/, not in a shared folder.

The test for shared code

Something moves to components/shared/ or hooks/ only when a second feature needs it. Not before.

Kind of codeShared locationFeature location
Reusable componentscomponents/shared/features/<name>/components/
UI primitives (button, dialog)components/ui/n/a
Hookshooks/features/<name>/hooks/
Utilitieslib/features/<name>/utils/
Zod schemasn/afeatures/<name>/schemas/

One home for every magic string

Hardcoded strings drift. Two files spell the same route differently, and one of them breaks quietly. So each kind of string gets exactly one file:

WhatWhere
API endpointssrc/configs/endpoints.ts
Routessrc/configs/routes.ts
Query keyssrc/configs/query-keys.ts
Mutation keyssrc/configs/mutation-keys.ts
Storage/cookie keyssrc/configs/storage.ts
Client env variablessrc/env/client.ts
Server env variablessrc/env/server.ts
src/configs/endpoints.ts
export const ENDPOINTS = {
  auth: {
    login: '/auth/login',
    register: '/auth/register',
  },
} as const;

as const keeps the values as literal types, so a typo in a key is a type error instead of a 404.

API calls

Components never call fetch or axios directly. API functions live in src/services/<feature>/<feature>.api.ts, use the shared client and the ENDPOINTS object, and return typed responses. Request and response types go in src/types/api/.

Server state and client state

Two tools, two jobs, and they don't overlap:

Anything that comes from the server. It handles caching, refetching and loading states. Every GET has reusable query options in src/lib/query-options/; mutations get their own hook in features/<name>/hooks/.

Forms

React Hook Form with Zod, through @hookform/resolvers/zod. The schema lives in the feature's schemas/ folder and the form type is inferred from it:

type LoginFormValues = z.infer<typeof loginSchema>;

Writing the type by hand next to the schema means keeping two things in sync, and one day they won't be.

Last updated on

On this page