One of the most common questions I get from developers starting out with React is: how do I organize my project files?
There is no single correct answer, but there are patterns that scale well and patterns that will make you regret them the moment your project grows past ten components. I want to share the structure I use, with a real-world example from Lumous AI, an open-source AI chat assistant I built.
Let me walk you through what the structure looks like, why each piece exists, and what principles guide the decisions.
The problem with the default structure
When you scaffold a new React project using Vite or Create React App, you get something like this:
src/
App.tsx
main.tsx
index.css
assets/
components/This is fine for a demo or a small personal project. But once you have more than a handful of components, you start running into issues:
- Everything goes into
components/, which becomes a dumping ground - There is no clear boundary between features
- API calls, state, hooks, and UI all mix together in unclear ways
- Finding a file means knowing exactly what it is called
The flat structure breaks down fast. You need a better mental model.
Feature-based organization
The approach I landed on is feature-based organization, sometimes called "feature-sliced design". Instead of grouping files by their technical role (all hooks together, all components together), you group them by the feature they belong to.
Each feature owns its own:
- API calls
- Components
- Hooks
- State / store
- Types
- Routes
Here is what the src/ directory looks like in Lumous AI:
src/
├── App.tsx
├── main.tsx
├── index.css
├── assets/
├── components/
│ ├── elements/
│ └── layout/
├── config/
├── lib/
├── pages/
│ ├── analytics/
│ ├── auth/
│ ├── chats/
│ ├── misc/
│ └── settings/
├── providers/
├── routes/
├── store/
├── theme/
└── utils/The bulk of the application logic lives inside pages/. Each subdirectory there is a full, self-contained feature module.
Inside a feature module
Let me show you what a single feature looks like. Here is the chats/ feature, which is the core of Lumous AI:
pages/chats/
├── api/
│ ├── createNewChat.ts
│ ├── deleteChat.ts
│ ├── getChat.ts
│ ├── getChats.ts
│ ├── getModels.ts
│ └── sendMessage.ts
├── components/
│ ├── ChatControls.tsx
│ ├── CodeBlock.tsx
│ ├── MarkdownComponents.tsx
│ ├── MessageBubble.tsx
│ ├── MessagesArea.tsx
│ ├── ModelSelector.tsx
│ ├── PromptInput.tsx
│ └── ReasoningToggle.tsx
├── context/
│ ├── ChatSessionsContext.tsx
│ └── ChatSessionsProvider.tsx
├── hooks/
│ ├── tests/
│ │ ├── useChatSessionsState.test.tsx
│ │ └── useChatStream.test.tsx
│ ├── useChatSessions.ts
│ └── useChatStream.ts
├── layout/
│ └── ChatLayout.tsx
├── routes/
│ ├── ChatIndexRedirect.tsx
│ └── Chats.tsx
├── store/
│ ├── useChatMessagesStore.ts
│ ├── useChatSettingsStore.ts
│ └── useChatStreamStore.ts
├── types/
│ └── index.ts
└── index.tsEverything the chats feature needs is right here, in one place. If I am working on the chat streaming logic, I open hooks/useChatStream.ts. If I need to add a new API call, I add a file to api/. If I want to see how data flows, I look at context/ and store/.
The key insight is: a developer working on this feature should almost never have to leave this folder.
The shared infrastructure
Not everything belongs to a single feature. Some things are shared across the entire application. These live at the top level of src/:
lib/
Third-party client configurations that the rest of the app depends on:
lib/
├── axios.ts — configured Axios instance with base URL and interceptors
├── firebase.ts — Firebase app initialization and auth export
└── queryClient.ts — React Query client configurationThese are things that are initialized once and reused. You configure them here so every feature imports from one place, not from scattered individual setups.
components/
Truly shared UI components that have no feature ownership:
components/
├── elements/
│ └── Modal.tsx
└── layout/
├── ChatSidebarHistory.tsx
├── MainLayout.tsx
├── Sidebar.tsx
└── index.tsI keep this folder lean. If a component is only used by one feature, it lives inside that feature's components/ directory. A component earns a spot in the global components/ folder only when it is genuinely shared by more than one feature.
store/
Global application state that spans features:
store/
└── useAppStore.tsFeature-level state lives inside each feature's own store/. The global store holds things like the currently authenticated user, global UI state like dark mode, or anything else multiple features need to read from.
routes/
The top-level router that assembles all feature routes into the app:
routes/
└── AppRoutes.tsxEach feature declares its own routes inside its routes/ folder. AppRoutes.tsx imports and composes them. This keeps routing decentralized where it makes sense, while having one clear entry point.
providers/, theme/, config/, utils/
providers/
└── AppThemeProvider.tsx
theme/
└── appTheme.ts
config/
└── index.ts
utils/
├── date.ts
├── storage.ts
└── time.tsThese follow a similar principle. providers/ wraps the app in React context providers at the root level. theme/ holds the design token definitions. config/ centralizes environment variables and app-wide constants so you never have process.env.VITE_SOMETHING scattered across fifty files. utils/ holds small, pure helper functions that have no component or API dependency.
The index.ts file convention
Every feature has an index.ts at its root:
// pages/chats/index.ts
export { Chats } from './routes/Chats'
export { ChatLayout } from './layout/ChatLayout'
export type { Message, ChatSession } from './types'This is the public API of the feature. Code outside the feature only imports from index.ts, not from deep paths inside the feature. This boundary is what makes the structure maintainable at scale.
If you see an import like:
import { MessageBubble } from '../chats/components/MessageBubble'that is a smell, because it reaches through the feature boundary. The preferred shape is:
import { MessageBubble } from '../chats'If MessageBubble is not exported from index.ts, it is a private implementation detail and should not be imported from outside.
Where tests live
In Lumous AI, tests are colocated with the code they test:
hooks/
├── tests/
│ ├── useChatSessionsState.test.tsx
│ └── useChatStream.test.tsx
├── useChatSessions.ts
└── useChatStream.tsTests sit next to the source files rather than in a top-level __tests__ directory. This has one major benefit: when you delete a feature, you delete its tests along with it. Nothing gets orphaned.
The test/ folder at the root level is only for global test setup, like configuring the test runner or mock providers:
test/
└── setup.tsThe toolchain files
At the project root you will find the configuration files that glue the stack together:
vite.config.ts — build config, path aliases, plugins
vitest.config.ts — test runner configuration
tsconfig.json — base TypeScript config
tsconfig.app.json — app-specific TypeScript config
tsconfig.node.json — Node-specific TypeScript config (for config files themselves)
tailwind.config.ts — Tailwind CSS configuration
postcss.config.mjs — PostCSS pipeline
eslint.config.js — linting rules
vercel.json — deployment configurationSplitting the TypeScript config into tsconfig.app.json and tsconfig.node.json is important. Config files like vite.config.ts run in a Node environment, not in the browser. Having separate configs lets you apply the right settings to each environment without compromises.
CI and deployment
.github/
└── workflows/
└── ci.ymlThe CI workflow runs on every pull request. Even a small project benefits from automated checks because they catch issues before they merge. The workflow for Lumous AI handles linting and tests at a minimum.
vercel.json configures the deployment target. For a React SPA, the most important setting here is usually a redirect rule to serve index.html for all routes, so client-side routing works correctly when you navigate directly to a URL.
What I would do differently
Looking back, there are a couple of things worth calling out:
Path aliases. Once the project grows, relative imports get messy. Setting up a @/ alias in vite.config.ts and tsconfig.json so you can write import { queryClient } from '@/lib/queryClient' instead of import { queryClient } from '../../lib/queryClient' is worth doing from day one.
Stricter barrel exports. The index.ts convention is great, but it is easy to let it slip and import directly from deep paths. A custom ESLint rule or import boundary tooling makes this enforceable.
Keep `components/` at the root small. The temptation is to put shared-looking UI into the global components/ folder early. Resist it. Premature sharing creates coupling. Let things live in their feature for a while, and only promote them when a second feature genuinely needs them.
Summary
Here is the mental model in one place:
| Folder | Contains |
|---|---|
src/pages/<feature>/ | Everything one feature needs: api, components, hooks, routes, store, types |
src/components/ | Shared UI that is genuinely used across multiple features |
src/lib/ | Third-party client setup (Axios, Firebase, React Query) |
src/store/ | Global state shared across features |
src/routes/ | Top-level route composition |
src/providers/ | Root-level React context providers |
src/utils/ | Pure helper functions with no component dependency |
src/config/ | App-wide constants and environment variable wrappers |
src/theme/ | Design tokens and theme definitions |
The rule that ties it all together: a feature should be self-contained, and cross-feature imports should go through `index.ts`.
This structure has worked well for Lumous AI and it scales cleanly as the application grows. More features are just more folders under pages/, each following the same internal shape.
If you want to see the actual code and how the pieces connect, the full repository is at github.com/supersver/Lumous-AI.