Chapter 5: Project Structure & Organization
How you organize your code determines how easy it is to find things, how safely you can change things, and how effectively a team can work in parallel. This chapter establishes the folder structure we will use throughout the book.
The Problem with Type-Based Organization
Most React tutorials teach type-based organization:
src/
├── components/
│ ├── Button.tsx
│ ├── Modal.tsx
│ ├── ProjectCard.tsx
│ ├── TaskList.tsx
│ └── UserAvatar.tsx
├── hooks/
│ ├── useAuth.tsx
│ ├── useProjects.tsx
│ └── useTasks.tsx
├── services/
│ ├── auth-service.ts
│ ├── project-service.ts
│ └── task-service.ts
├── types/
│ ├── auth.ts
│ ├── project.ts
│ └── task.ts
└── utils/
├── date.ts
└── format.ts
This works for small projects. It fails at scale because:
- Change amplification: Adding a new "Projects" feature requires touching files in
components/,hooks/,services/,types/, and possiblyutils/. A single feature change ripples across five directories. - Low cohesion: Files related to the same feature are scattered. Understanding how "Projects" works requires jumping between directories.
- High coupling: With no boundaries between features, it is easy for the Tasks feature to reach into Projects internals and vice versa. Dependencies become invisible.
- Scaling pain: When you have 50 components in
components/, finding the one you need is a chore. When you have 30 hooks inhooks/, the directory becomes a dumping ground.
Feature-Based Organization
We organize by feature (what the code does for the user) rather than by type (what kind of file it is):
src/
├── app/ # Application shell
│ ├── app.tsx # Root component
│ ├── providers.tsx # Provider composition
│ └── router.tsx # Router configuration
│
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page
│ ├── _authenticated.tsx # Auth layout (route group)
│ ├── _authenticated/
│ │ ├── dashboard.tsx
│ │ ├── projects/
│ │ │ ├── index.tsx # Project list
│ │ │ └── $projectId.tsx # Project detail
│ │ ├── tasks/
│ │ │ ├── index.tsx
│ │ │ └── $taskId.tsx
│ │ └── settings/
│ │ ├── index.tsx
│ │ └── profile.tsx
│ └── _public.tsx # Public layout
│ ├── login.tsx
│ └── register.tsx
│
├── features/ # Feature modules
│ ├── auth/
│ │ ├── components/
│ │ │ ├── login-form.tsx
│ │ │ ├── register-form.tsx
│ │ │ └── auth-guard.tsx
│ │ ├── hooks/
│ │ │ ├── use-auth.ts
│ │ │ └── use-session.ts
│ │ ├── services/
│ │ │ └── auth-service.ts
│ │ ├── schemas/
│ │ │ ├── login-schema.ts
│ │ │ └── register-schema.ts
│ │ ├── types/
│ │ │ └── auth.ts
│ │ └── index.ts # Public API of this feature
│ │
│ ├── projects/
│ │ ├── components/
│ │ │ ├── project-card.tsx
│ │ │ ├── project-list.tsx
│ │ │ ├── project-form.tsx
│ │ │ └── project-board.tsx
│ │ ├── hooks/
│ │ │ ├── use-projects.ts
│ │ │ └── use-project-mutations.ts
│ │ ├── services/
│ │ │ └── project-service.ts
│ │ ├── schemas/
│ │ │ └── project-schema.ts
│ │ ├── types/
│ │ │ └── project.ts
│ │ └── index.ts
│ │
│ ├── tasks/
│ │ ├── components/
│ │ │ ├── task-card.tsx
│ │ │ ├── task-list.tsx
│ │ │ ├── task-form.tsx
│ │ │ └── task-detail.tsx
│ │ ├── hooks/
│ │ │ ├── use-tasks.ts
│ │ │ └── use-task-mutations.ts
│ │ ├── services/
│ │ │ └── task-service.ts
│ │ ├── schemas/
│ │ │ └── task-schema.ts
│ │ ├── types/
│ │ │ └── task.ts
│ │ └── index.ts
│ │
│ └── notifications/
│ ├── components/
│ │ ├── notification-bell.tsx
│ │ └── notification-list.tsx
│ ├── hooks/
│ │ └── use-notifications.ts
│ ├── services/
│ │ └── notification-service.ts
│ └── index.ts
│
├── shared/ # Shared code (used across features)
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # shadcn/ui components (generated)
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── input.tsx
│ │ │ └── ...
│ │ ├── layout/
│ │ │ ├── page-header.tsx
│ │ │ ├── sidebar.tsx
│ │ │ └── content-area.tsx
│ │ └── feedback/
│ │ ├── error-boundary.tsx
│ │ ├── loading-spinner.tsx
│ │ └── empty-state.tsx
│ ├── hooks/ # Shared hooks
│ │ ├── use-debounce.ts
│ │ ├── use-media-query.ts
│ │ └── use-local-storage.ts
│ ├── lib/ # Shared libraries and utilities
│ │ ├── api-client.ts # HTTP client configuration
│ │ ├── query-client.ts # TanStack Query client setup
│ │ ├── effect-runtime.ts # Effect ManagedRuntime setup
│ │ ├── cn.ts # Tailwind class merge utility
│ │ └── format.ts # Date/number formatting
│ ├── services/ # Infrastructure services
│ │ ├── http-service.ts # Effect HTTP service
│ │ └── storage-service.ts # Local storage service
│ └── types/ # Shared type definitions
│ ├── api.ts # API response types
│ └── common.ts # Common utility types
│
├── styles/ # Global styles
│ └── app.css # Tailwind imports + theme
│
├── test/ # Test utilities
│ ├── setup.ts # Vitest setup
│ ├── render.tsx # Custom render with providers
│ ├── mocks/ # Mock data and services
│ │ ├── handlers.ts # MSW request handlers
│ │ └── data.ts # Fixture data
│ └── factories/ # Test data factories
│ ├── user-factory.ts
│ └── project-factory.ts
│
├── main.tsx # Application entry point
├── route-tree.gen.ts # Generated by TanStack Router (git-ignored)
└── vite-env.d.ts # Vite type declarations
Key Principles
1. Features Own Their Code
Each feature directory contains everything that feature needs: components, hooks, services, schemas, and types. To understand how "Projects" works, you look in features/projects/. Everything is there.
2. Features Expose a Public API
Each feature has an index.ts that exports only what other features are allowed to use:
// features/projects/index.ts
// Public components
export { ProjectCard } from "./components/project-card";
export { ProjectList } from "./components/project-list";
// Public hooks
export { useProjects } from "./hooks/use-projects";
export { useProjectMutations } from "./hooks/use-project-mutations";
// Public types
export type { Project, ProjectStatus } from "./types/project";
// NOT exported: internal components, services, schemas
// Other features cannot access ProjectForm or project-service directly
This creates an explicit boundary. If you need something from Projects, import it from @/features/projects. You cannot reach into its internal services/ directory.
3. Shared Code Is Genuinely Shared
The shared/ directory contains code that is used by two or more features. If something is only used by one feature, it belongs in that feature, not in shared/.
The threshold rule: do not move code to shared/ until the second feature needs it. Premature extraction creates coupling without necessity.
4. Routes Are Thin
Route files in routes/ should be thin — they compose feature components and set up data loading, but contain minimal business logic:
// src/routes/_authenticated/projects/$projectId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { ProjectDetail } from "@/features/projects";
import { projectQueryOptions } from "@/features/projects/hooks/use-projects";
export const Route = createFileRoute("/_authenticated/projects/$projectId")({
loader: ({ params, context }) =>
context.queryClient.ensureQueryData(
projectQueryOptions(params.projectId)
),
component: ProjectDetailPage,
});
function ProjectDetailPage() {
const { projectId } = Route.useParams();
return <ProjectDetail projectId={projectId} />;
}
The route file delegates to ProjectDetail from the projects feature. It does not contain project rendering logic itself.
Naming Conventions
Files: kebab-case
project-card.tsx ✅
ProjectCard.tsx ❌
projectCard.tsx ❌
project_card.tsx ❌
Kebab-case is:
- Case-insensitive-filesystem safe (no issues on macOS vs Linux)
- Consistent (no mental overhead deciding between PascalCase and camelCase)
- Scannable (dashes are easier to read than smashed-together words)
Components: PascalCase exports
// File: project-card.tsx
export function ProjectCard({ project }: ProjectCardProps) {
// ...
}
The file name is kebab-case (project-card.tsx). The exported component is PascalCase (ProjectCard). This is the React convention.
Hooks: camelCase with use prefix
// File: use-projects.ts
export function useProjects(organizationId: string) {
// ...
}
Services: PascalCase class-based (Effect pattern)
// File: project-service.ts
export class ProjectService extends Context.Tag("ProjectService")<
ProjectService,
{ /* ... */ }
>() {}
Schemas: PascalCase with Schema suffix
// File: project-schema.ts
export const CreateProjectSchema = Schema.Struct({ /* ... */ });
export const UpdateProjectSchema = Schema.Struct({ /* ... */ });
Types: PascalCase
// File: project.ts
export interface Project { /* ... */ }
export type ProjectStatus = "active" | "archived" | "completed";
Directory Boundary Rules
These rules prevent the codebase from becoming a tangled dependency graph:
Rule 1: Features never import from other features' internals
// ✅ Import from the feature's public API
import { ProjectCard } from "@/features/projects";
// ❌ Never reach into another feature's internals
import { ProjectCard } from "@/features/projects/components/project-card";
Rule 2: Features can import from shared/
// ✅ Shared utilities are available to all features
import { Button } from "@/shared/components/ui/button";
import { useDebounce } from "@/shared/hooks/use-debounce";
Rule 3: shared/ never imports from features/
Shared code must not depend on feature code. If it does, it is not truly shared — it belongs in the feature.
Rule 4: Routes import from features, not the other way around
// ✅ Route composes features
// routes/_authenticated/dashboard.tsx
import { ProjectList } from "@/features/projects";
import { TaskSummary } from "@/features/tasks";
// ❌ Feature imports from routes
// features/projects/components/project-list.tsx
import { Route } from "@/routes/_authenticated/projects"; // NO
Enforcing Boundaries
You can enforce these rules with ESLint:
// eslint.config.js (relevant section)
{
rules: {
"no-restricted-imports": ["error", {
patterns: [
{
group: ["@/features/*/components/*", "@/features/*/hooks/*", "@/features/*/services/*"],
message: "Import from the feature's index.ts instead of reaching into internals."
}
]
}]
}
}
When to Create a New Feature
Create a new feature directory when:
- New business domain: A new area of the app with its own data, logic, and UI (e.g., "Billing", "Analytics")
- Distinct user-facing capability: Something a user would describe as a "feature" (e.g., "Search", "Notifications")
- Independent evolution: The code will change for different reasons than existing features
Do not create a new feature for:
- A single component (put it in the nearest feature or
shared/) - A utility function (goes in
shared/lib/) - A thin wrapper around a library (goes in
shared/lib/)
The shared/lib/ Directory
This is where application-wide utilities live:
// shared/lib/cn.ts — Tailwind class merging utility
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// shared/lib/query-client.ts — TanStack Query client singleton
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
// shared/lib/effect-runtime.ts — Effect runtime for React
import { ManagedRuntime, Layer } from "effect";
import { HttpService } from "@/shared/services/http-service";
import { StorageService } from "@/shared/services/storage-service";
// Compose all infrastructure layers
const AppLayer = Layer.mergeAll(
HttpService.Live,
StorageService.Live,
);
// Create a managed runtime that React components can use
export const AppRuntime = ManagedRuntime.make(AppLayer);
Application Entry Point
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@/app/app";
import "@/styles/app.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found. Ensure index.html has <div id='root'></div>");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);
// src/app/app.tsx
import { RouterProvider } from "@tanstack/react-router";
import { Providers } from "./providers";
import { router } from "./router";
export function App() {
return (
<Providers>
<RouterProvider router={router} />
</Providers>
);
}
// src/app/providers.tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib/query-client";
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
{import.meta.env.VITE_ENABLE_DEVTOOLS === "true" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}
Summary
- ✅ Feature-based organization over type-based — code related to a feature lives together
- ✅ Public APIs via
index.ts— explicit boundaries between features - ✅ Thin routes — route files compose features, they do not contain business logic
- ✅ Shared code is genuinely shared — not promoted prematurely
- ✅ Naming conventions — kebab-case files, PascalCase components, camelCase hooks
- ✅ Boundary rules — enforced through convention and optionally through ESLint
- ✅ Scalable — adding a new feature means adding a new directory, not modifying ten existing ones
This structure scales from a solo developer to a large team. Each feature can be worked on independently, tested independently, and understood independently. The shared layer provides the common language, and the routes layer ties everything together.