One of the first things that overwhelms a new React developer is not JSX or state — it is the empty folder. The tutorial ended, the app works, and now there are forty files sitting in src with no obvious home. This guide walks through a folder structure that scales from a weekend project to a serious application, and more importantly explains the reasoning behind each decision so you can adapt it instead of copying it blindly.
Why structure matters more than it seems
A small React app can survive almost any layout. The problem shows up at around the fifty-component mark, when three things start happening: you cannot find files without the search bar, you stop knowing whether a component is safe to change, and imports turn into long chains of ../../../ that break every time you move something.
Good structure is really about answering one question quickly: if I need to change this feature, which files do I touch? A structure that makes that answer obvious is a good structure, regardless of what any style guide says.
The structure that stops working
Most beginners start by grouping files by what they are:
src/
components/
hooks/
utils/
context/
pages/
This is fine at first and it is what most tutorials show. It breaks down for a specific reason: a feature is never contained in one folder. Building a checkout flow means opening components, hooks, utils, context and pages simultaneously, and deleting that feature later means hunting through all five for orphaned files. The folder names describe the technology, not the product.
A feature-based structure that scales
The alternative is to group by what the code is for, and keep a small shared layer for genuinely global things.
src/
app/
App.jsx
routes.jsx
providers.jsx
features/
auth/
components/
hooks/
api/
authSlice.js
index.js
products/
components/
hooks/
api/
index.js
checkout/
components/
hooks/
lib/
styles/
assets/
Now the checkout feature lives in one place. A new developer given a bug in checkout knows exactly where to look. Deleting the feature means deleting one folder and one route entry.
The rule for what goes in the top-level components folder is simple and worth enforcing: it belongs there only if it knows nothing about your business domain. A Button, Modal, Input or Spinner qualifies. A ProductCard does not, because it only makes sense inside the products feature.
The barrel file and the public API idea
Each feature folder gets an index.js that exports only what the outside world is allowed to use:
// features/auth/index.js
export { LoginForm } from "./components/LoginForm";
export { useAuth } from "./hooks/useAuth";
Everything else inside the folder is private by convention. Other features import from "@/features/auth" and never reach into internal files. This single habit prevents the most damaging kind of coupling, where a small refactor inside one feature silently breaks four others.
One caveat: very large barrel files can slow down bundlers and hurt tree-shaking in some setups. Keep them small and export deliberately rather than re-exporting everything.
Fix your imports before you have a hundred of them
Path aliases take two minutes to configure and pay for themselves immediately. In a Vite project:
import path from "path";
export default {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") }
}
};
Add the matching paths entry to jsconfig.json or tsconfig.json so the editor understands it too. Now import { Button } from "@/components/Button" works from any depth, and moving a file no longer rewrites a dozen import statements.
Naming conventions worth committing to
The specific convention matters far less than consistency, but these are the ones most teams converge on:
- Component files in PascalCase, matching the component name:
ProductCard.jsx. - Hooks in camelCase with the
useprefix:useCart.js. - Utilities and config in camelCase:
formatCurrency.js. - Folders in lowercase or kebab-case:
product-details. - Constants in SCREAMING_SNAKE_CASE inside the file, not the filename.
Colocate tests and styles with the component rather than mirroring the whole tree in a separate tests folder. ProductCard.jsx, ProductCard.test.jsx and ProductCard.module.css sitting together is easier to maintain than three parallel hierarchies.
Where API calls belong
A common beginner mistake is calling fetch directly inside components. It works, and it makes every component harder to test, harder to reuse and impossible to change endpoints without a search-and-replace.
Create one configured client in lib/:
// lib/apiClient.js
const BASE_URL = import.meta.env.VITE_API_URL;
export async function apiRequest(path, options = {}) {
const res = await fetch(BASE_URL + path, {
headers: { "Content-Type": "application/json", ...options.headers },
...options
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
Then each feature owns its own thin layer on top:
// features/products/api/getProducts.js
import { apiRequest } from "@/lib/apiClient";
export const getProducts = () => apiRequest("/products");
Components call a hook, the hook calls the API function, the API function calls the client. Each layer is independently testable and the base URL exists in exactly one place.
Keep components small by extracting logic into hooks
Structure is not only folders. A 400-line component is a structural problem even if it sits in the right directory. The usual cause is mixing three responsibilities: fetching data, transforming it, and rendering it.
Extract the first two into a custom hook:
function useProducts() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
getProducts()
.then((data) => { if (!cancelled) setProducts(data); })
.catch((e) => { if (!cancelled) setError(e); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { products, loading, error };
}
The component that consumes this becomes a handful of lines and can be understood at a glance. Note the cancelled flag — it prevents state updates after unmount, a bug that produces warnings in development and memory pressure in long-lived apps.
State: not everything needs a global store
New developers frequently reach for Redux or a context provider on day one and end up with a store holding form inputs. A more useful hierarchy:
- Local state with
useState— the default, and correct for the large majority of cases. - Lifted state — move it to the nearest common parent when two siblings need it.
- Context — for genuinely app-wide, rarely-changing values like theme, locale or the authenticated user.
- A data-fetching library — for server state, which has caching, revalidation and loading semantics that a global store handles badly.
- A global store — for complex client state shared across unrelated parts of the tree.
Most of what beginners call “global state” is actually server state, and treating it as such removes an enormous amount of code.
Environment variables and configuration
Never hardcode API URLs or keys. Use .env files, commit a .env.example with the variable names and no values, and add the real files to .gitignore. Remember that anything exposed to the frontend bundle is public — the VITE_ or REACT_APP_ prefix marks a variable as readable by anyone who opens devtools. Secret keys belong on a server, not in a React app.
Migrating an existing project without breaking it
Restructuring a working app feels risky, and doing it in one enormous commit genuinely is. A safer sequence keeps the app running throughout.
Start by adding the path alias, because every later move becomes cheaper once imports no longer depend on relative depth. Commit that alone and confirm the build still passes. Next, create the features directory and move one feature into it — pick the most self-contained one, not the most important. Move its components, hooks and API calls together, add the barrel file, and update the imports that referenced it.
Run the app, commit, and stop there for the day. Repeat one feature at a time. Anything that resists the move, because four unrelated parts of the app reach into it, is telling you something useful about your coupling, and that is worth fixing before continuing rather than papering over with a shared folder.
Leave the genuinely generic components where they are until the end. By that point the boundary between “shared UI” and “belongs to a feature” will be obvious, because you will have seen which components only one feature ever imports.
One practical tip: modern editors update imports automatically when you drag a file, but they miss dynamic imports and string references in routing config. Search the codebase for the old path after each move rather than trusting the tooling completely.
Frequently asked questions
Should I start with a feature-based structure on a small project?
Start with the simple type-based layout if the project has under roughly fifteen components. Migrate to features the moment you notice yourself opening four folders to change one thing. Premature structure is its own kind of overhead.
Do I need TypeScript for this to work?
No. Every pattern here applies to plain JavaScript. TypeScript makes the feature boundaries easier to enforce, because a wrongly-imported internal file becomes a type error rather than a silent dependency, but it is not a prerequisite.
Where do I put shared types or constants?
Feature-specific ones go inside the feature. Genuinely global ones go in lib/ or a small types/ folder. If you find a “shared” folder growing without limit, that is a sign something in it actually belongs to one feature.
Is there an officially correct React folder structure?
No. React is a library, not a framework, and deliberately takes no position on this. Frameworks built on top of it — Next.js and Remix, for example — do impose routing conventions, and you should follow those where they apply, layering feature folders inside whatever the framework requires.
Conclusion
A good React structure is one where the location of a file is predictable and the blast radius of a change is small. Group by feature rather than by file type, give each feature a deliberate public API, configure path aliases early, push data fetching out of components into hooks and a client layer, and resist the pull towards global state. None of this is difficult individually, and together it is the difference between a codebase that stays pleasant at month six and one that does not.