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 code | Shared location | Feature location |
|---|---|---|
| Reusable components | components/shared/ | features/<name>/components/ |
| UI primitives (button, dialog) | components/ui/ | n/a |
| Hooks | hooks/ | features/<name>/hooks/ |
| Utilities | lib/ | features/<name>/utils/ |
| Zod schemas | n/a | features/<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:
| What | Where |
|---|---|
| API endpoints | src/configs/endpoints.ts |
| Routes | src/configs/routes.ts |
| Query keys | src/configs/query-keys.ts |
| Mutation keys | src/configs/mutation-keys.ts |
| Storage/cookie keys | src/configs/storage.ts |
| Client env variables | src/env/client.ts |
| Server env variables | src/env/server.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