Project Structure & Conventions
A starter app fits comfortably in a flat src/ folder, but that arrangement collapses the moment the project grows past a dozen screens. The way you lay out files quietly shapes how easy the codebase is to navigate, refactor, and onboard into. This page lays out an opinionated, feature-first structure for React 18/19 apps built with Vite—where related code lives together, shared code is deliberate, and conventions are consistent enough that any file’s purpose is obvious from its location.
Organize by feature, not by file type
The most common starter layout groups files by kind: every component in components/, every hook in hooks/, every helper in utils/. It reads well in a tutorial and falls apart at scale, because a single feature ends up smeared across four folders and unrelated code piles up in each.
Instead, group by feature. A feature is a vertical slice of the product—checkout, authentication, the dashboard—and everything that slice needs lives in one folder.
src/
features/
cart/
components/
CartPage.jsx
CartLineItem.jsx
hooks/
useCart.js
api/
cart.api.js
cart.test.jsx
index.js
auth/
components/
LoginForm.jsx
hooks/
useAuth.js
index.js
shared/
components/
hooks/
lib/
app/
App.jsx
routes.jsx
main.jsx
When a requirement changes, the edit usually touches one folder rather than five scattered files. New engineers can read a feature top to bottom without a treasure hunt, and deleting a retired feature means deleting one directory.
Colocate everything a unit owns
Colocation is the single most useful rule: keep a thing next to the code that uses it. A component’s styles, tests, stories, and private subcomponents belong beside the component, not in a parallel folder tree mirroring src/.
features/cart/components/
CartLineItem.jsx
CartLineItem.module.css
CartLineItem.test.jsx
CartLineItem.stories.jsx
The payoff is locality of behavior: when you open CartLineItem.jsx, its tests and styles are right there, and moving or removing the component moves or removes all of it at once.
import styles from "./CartLineItem.module.css";
export function CartLineItem({ item, onRemove }) {
return (
<li className={styles.row}>
<span>{item.name}</span>
<span>${(item.price * item.qty).toFixed(2)}</span>
<button type="button" onClick={() => onRemove(item.id)}>
Remove
</button>
</li>
);
}
A hook, helper, or component used by exactly one feature should live inside that feature. Promote it to
shared/only when a second feature genuinely needs it—not in anticipation.
Draw a clear line between shared and feature code
The shared/ (or common/) layer is for code with no business meaning: a Button, a useDebounce hook, a formatDate helper. Feature code may freely import from shared/, but the dependency must never flow the other way.
| Layer | Contains | May import from | Example |
|---|---|---|---|
shared/ | Generic, reusable, business-agnostic | Other shared/ only | Button, useMediaQuery |
features/<x>/ | One vertical product slice | shared/, same feature | CartPage, useCart |
app/ | Wiring: routing, providers, layout | features/, shared/ | App.jsx, routes.jsx |
If shared/ ever needs to import from features/, that code was not actually shared—it belongs in a feature. Keeping the dependency direction one-way (app → features → shared) prevents circular imports and keeps features independently deletable.
Adopt consistent naming conventions
Conventions only help if they are predictable, so pick one rule per category and apply it everywhere:
- Component files:
PascalCase.jsxmatching the exported component (LoginForm.jsxexportsLoginForm). - Hooks:
useThing.js, always prefixed withuse. - Non-component modules:
camelCase.jsor descriptive suffixes (cart.api.js,date.utils.js). - Tests: same name plus
.test.jsx; stories plus.stories.jsx. - One main export per file, named—reserve default exports for route components that frameworks lazy-load.
// useCart.js — a hook file, prefixed and camelCased after `use`
import { useContext } from "react";
import { CartContext } from "./CartContext";
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) {
throw new Error("useCart must be used within <CartProvider>");
}
return ctx;
}
Use barrel files deliberately
A barrel is an index.js that re-exports a folder’s public surface, so callers import from the feature root rather than reaching into its internals.
// features/cart/index.js
export { CartPage } from "./components/CartPage";
export { useCart } from "./hooks/useCart";
// CartLineItem and CartContext are internal — intentionally not exported.
// Consumers import the public API, not deep paths.
import { CartPage, useCart } from "@/features/cart";
Barrels define an explicit public boundary for each feature, which is their real value. Use them at the feature root, but be sparing inside large shared/ libraries—a single huge barrel can defeat tree-shaking and create import cycles when modules re-import their own barrel.
Pair barrels with a path alias such as
@/(configured invite.config.jsandjsconfig.json/tsconfig.json) so imports read@/features/cartinstead of../../../features/cart.
Best Practices
- Group source by feature so a change touches one folder, not a file-type sprawl.
- Colocate components with their tests, styles, stories, and private children.
- Keep
shared/strictly business-agnostic and the dependency direction one-way. - Promote code to
shared/only on the second real use, never preemptively. - Apply one consistent naming rule per category—
PascalCasecomponents,use-prefixed hooks. - Expose each feature through a small barrel
index.jsand import via a@/path alias. - Reserve default exports for lazy-loaded route components; prefer named exports elsewhere.