Type something to search...
Folder Structure Best Practices for Scalable React Projects

Folder Structure Best Practices for Scalable React Projects

Every React project starts tidy. There's a components folder, maybe a hooks folder, and you can find anything in seconds. Six months later components holds 140 files, changing the checkout flow means editing files in five different top-level folders, and nobody is sure whether utils/format.ts is safe to change because half the app imports it.

React doesn't prescribe a folder structure, which is freeing at the start and costly later. The structure you choose decides how easy it is to find code, how far a change ripples, and whether a new developer can work on one area without understanding all of them.

This guide covers the structures that hold up as React projects grow: why grouping by feature beats grouping by file type, what belongs in shared folders, how to colocate tests and styles, when barrel files help and when they hurt, and how to enforce boundaries with path aliases and lint rules so the structure doesn't decay.

Start Simple, Then Evolve

For a small app, a prototype, or anything under a couple of dozen components, a flat structure is perfectly fine:

src/
  components/
    Header.tsx
    TodoItem.tsx
    TodoList.tsx
  hooks/
    useTodos.ts
  App.tsx
  main.tsx

Don't build a ten-folder architecture for a weekend project. The goal isn't to predict the final shape on day one. It's to recognize when the simple structure stops working and to have a clear direction to move in. The usual signs:

  • A single folder has more files than you can scan at a glance.
  • Changing one feature means jumping between components, hooks, services, and types.
  • You're afraid to delete files because you can't tell what uses them.

Group by Feature, Not by File Type

The most common early structure groups files by what they are:

src/
  components/
    CartItem.tsx
    CartSummary.tsx
    LoginForm.tsx
    ProductCard.tsx
    ProductGrid.tsx
  hooks/
    useCart.ts
    useAuth.ts
    useProducts.ts
  services/
    cartApi.ts
    authApi.ts
    productApi.ts
  types/
    cart.ts
    product.ts

It looks organized, but it scatters every feature across the whole tree. To understand the cart, you open four folders. To delete the cart, you hunt through all of them.

Grouping by what the code does keeps related code together:

src/
  features/
    auth/
      components/
        LoginForm.tsx
      hooks/
        useAuth.ts
      api.ts
      types.ts
      index.ts
    cart/
      components/
        CartItem.tsx
        CartSummary.tsx
      hooks/
        useCart.ts
      api.ts
      types.ts
      index.ts
    products/
      components/
        ProductCard.tsx
        ProductGrid.tsx
      hooks/
        useProducts.ts
      api.ts
      types.ts
      index.ts

Now each feature is a self-contained unit. Working on the cart means working inside features/cart. Deleting a feature is deleting a folder. Different teams can own different folders with minimal conflicts. The internal shape of each feature can still use type-based subfolders, because at that scale they're small and easy to scan.

A Complete Structure for a Growing App

Here's a structure that works well for medium to large single-page apps built with Vite and React Router:

src/
  app/
    App.tsx
    router.tsx
    providers.tsx
  pages/
    HomePage.tsx
    ProductPage.tsx
    CheckoutPage.tsx
  features/
    auth/
    cart/
    products/
    checkout/
  shared/
    ui/
      Button.tsx
      Modal.tsx
      Spinner.tsx
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    lib/
      apiClient.ts
      formatCurrency.ts
    config/
      env.ts
  assets/
  main.tsx

What each top-level folder is for:

  • app/ is the application shell: the root component, router setup, and global providers like the query client, theme, and auth. Code here wires everything together and nothing imports from it.
  • pages/ contains one thin component per route. A page composes features and decides layout but holds little logic of its own.
  • features/ contains the business functionality: everything that relates to a specific user-facing capability.
  • shared/ contains code with no knowledge of any feature: generic UI components, generic hooks, the API client, formatting helpers, and config.
  • assets/ holds images, fonts, and other static files imported by code.

The most important rule is the direction of dependencies. app can import from everything. pages import from features and shared. features import from shared. shared imports from nothing in the app. If a dependency points the wrong way, it's a sign that code is in the wrong folder.

If you want a fully specified version of these layers, with formal rules for slices and segments, read about Feature-Sliced Design for large React codebases. The structure above is a lighter version of the same idea.

What Goes in Shared (and What Doesn't)

shared is where structures usually rot. Anything that "might be reused" gets dropped there, and it becomes a junk drawer that everything depends on.

A useful test: would this code make sense in a completely different app? A Button, a useDebounce hook, a formatCurrency function, or a typed fetch wrapper would. A ProductPrice component that knows about discounts and currencies for your store would not. It belongs in features/products.

Other guidelines:

  • Move code to shared when it's actually used twice, not when you imagine it might be. Premature sharing creates abstractions shaped around one caller.
  • Keep shared generic. A shared component that takes a product prop has a feature dependency hiding inside it.
  • Split shared by kind. ui, hooks, lib, config, and types keep it scannable. Generic hooks like those in building your own custom hooks fit naturally in shared/hooks.

Colocate Everything a Component Needs

Colocation means keeping files next to the code that uses them. Tests, styles, stories, and small helpers live beside the component, not in parallel trees.

features/cart/components/CartItem/
  CartItem.tsx
  CartItem.module.css
  CartItem.test.tsx
  CartItem.stories.tsx
  index.ts

The index.ts is a one-line re-export so imports stay short:

// features/cart/components/CartItem/index.ts
export { CartItem } from "./CartItem";

A component folder like this is worth it once a component has two or more companion files. A single-file component can stay as CartItem.tsx without a folder.

Colocated tests have practical benefits. You can see at a glance which components lack tests, moving a component moves its tests with it, and tools like Vitest find *.test.tsx files anywhere. The Vitest and Testing Library guide uses exactly this layout.

The opposite pattern, a top-level __tests__ folder mirroring src, means every rename or move has to happen twice, and the two trees inevitably drift apart.

Give Each Feature a Public API

Once features exist as folders, the next problem is other code reaching into their internals:

// Reaching into private files of another feature
import { CartItem } from "@/features/cart/components/CartItem/CartItem";
import { calculateTotals } from "@/features/cart/utils/calculateTotals";

Now the cart can't reorganize its own files without breaking unrelated code. Fix this by giving each feature one entry point that exports only what other parts of the app should use:

// features/cart/index.ts
export { CartSummary } from "./components/CartSummary";
export { AddToCartButton } from "./components/AddToCartButton";
export { useCart } from "./hooks/useCart";
export type { CartLine } from "./types";

Everything else is private to the feature. Consumers import from the feature root:

import { AddToCartButton, useCart } from "@/features/cart";

This is the same idea as a package's public exports. The feature can refactor freely behind its index.ts as long as those exports keep working.

A Word on Barrel Files

An index.ts that re-exports things is called a barrel file. A barrel per feature, as above, is useful. Barrels everywhere cause problems:

  • Slower dev servers and tests. Importing one function from a large barrel can force the tool to load every module the barrel references.
  • Circular dependencies become easy to create when files inside a feature import from their own barrel.
  • Weaker tree shaking in some setups, especially when modules have side effects.

Keep barrels to feature boundaries and component folders. Inside a feature, import files directly with relative paths, never through the feature's own index.ts.

Set Up Path Aliases

Deep relative imports like ../../../../shared/ui/Button are hard to read and break when files move. A path alias maps @/ to src/. With Vite, configure both TypeScript and the bundler.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
// vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

The paths block goes in the tsconfig that covers your app source, usually tsconfig.app.json in Vite's template. TypeScript uses it for type checking and editor navigation. Vite uses resolve.alias for the actual build. Both are needed, because TypeScript doesn't rewrite import paths. Vitest reads the Vite config, so tests pick up the alias automatically. For a full Vite setup walkthrough, see building React apps with Vite.

A reasonable convention is to use the alias for imports that cross a top-level boundary (@/shared/ui, @/features/cart) and relative imports within the same feature.

Enforce Boundaries With Lint Rules

Conventions written in a README erode under deadline pressure. Lint rules don't. ESLint's built-in no-restricted-imports rule can block deep imports into features:

// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended, {
  files: ["src/**/*.{ts,tsx}"],
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["@/features/*/*"],
            message: "Import from the feature's public API (@/features/name) instead.",
          },
        ],
      },
    ],
  },
});

For directional rules, such as "shared must never import from features," eslint-plugin-import provides import/no-restricted-paths:

// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";

export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended, {
  files: ["src/**/*.{ts,tsx}"],
  plugins: { import: importPlugin },
  rules: {
    "no-restricted-imports": [
      "error",
      { patterns: [{ group: ["@/features/*/*"], message: "Use the feature's public API." }] },
    ],
    "import/no-restricted-paths": [
      "error",
      {
        zones: [
          { target: "./src/shared", from: ["./src/features", "./src/pages", "./src/app"] },
          { target: "./src/features", from: ["./src/pages", "./src/app"] },
        ],
      },
    ],
  },
});

Now the dependency direction is checked on every commit, and a wrong import fails CI with a clear message instead of quietly becoming permanent. Note that import/no-restricted-paths needs a resolver that understands your @/ alias, such as eslint-import-resolver-typescript, to catch aliased imports.

Naming Conventions

Consistency matters more than the specific choice, but these conventions are common and work well:

  • Components: PascalCase file names that match the export, such as ProductCard.tsx exporting ProductCard.
  • Hooks: camelCase starting with use, such as useCart.ts.
  • Utilities and other modules: camelCase, such as formatCurrency.ts or apiClient.ts.
  • Folders: kebab-case or camelCase for features (order-history or orderHistory). Pick one.
  • Named exports over default exports. They make renames safer, autocomplete better, and re-exports simpler.
  • One component per file, except for tiny private helpers used only by that component.

Avoid generic file names like utils.ts or helpers.ts at the root of a feature. Name files after what they do (calculateTotals.ts), so the file tree itself documents the code.

Common Mistakes With React Folder Structure

  • Over-engineering on day one. A small app doesn't need a layered architecture. Start flat and restructure when the pain shows up.
  • Grouping only by file type. It scatters every feature across the tree and makes changes touch many folders.
  • Turning shared into a junk drawer. Only truly generic, feature-agnostic code belongs there.
  • Reaching into other features' internals. Expose a public API per feature and import only from it.
  • Barrels everywhere. They slow tooling and invite circular imports. Use them only at boundaries.
  • Mirrored test trees. Colocate tests with the code they test.
  • Unenforced rules. If boundaries matter, encode them in ESLint so they survive deadlines.
  • Huge feature folders. When a feature grows to dozens of components, split it into smaller features rather than adding more nesting.

Frequently Asked Questions (FAQ) About React Folder Structure

There isn't one universal answer, but grouping code by feature scales best for most apps. Use an app folder for setup, a pages folder for route components, a features folder for business functionality, and a shared folder for generic code. Start simpler for small projects and evolve toward this as the app grows.

By feature, once the app has more than a handful of screens. Type-based folders like components and hooks look tidy at first but spread each feature across the whole tree. Feature folders keep related code together, make features easy to delete, and let teams work independently.

Next to the code they test, for example CartItem.test.tsx beside CartItem.tsx. Colocated tests move with their components, make missing tests obvious, and are found automatically by Vitest and Jest. End-to-end tests are the exception and usually live in a top-level e2e folder.

Not inherently, but they're often overused. One index.ts per feature as a public API is useful. Barrels in every folder slow down dev servers and tests, make circular imports easy, and can hurt tree shaking. Keep them at boundaries and import directly inside a feature.

Encode the rules in ESLint. Use no-restricted-imports to block deep imports into features and import/no-restricted-paths to enforce dependency direction between layers. Run lint in CI so violations fail the build instead of relying on code review to catch them.

When it's used by at least two features and has no knowledge of any specific feature. A generic button, a debounce hook, or a date formatter belongs in shared. A component that understands products or orders belongs in that feature, even if two pages use it.

Conclusion

A scalable React folder structure isn't about picking the perfect template on day one. It's about a few principles applied consistently: group code by feature, keep shared code genuinely generic, colocate tests and styles with their components, and make dependencies point in one direction, from app to pages to features to shared.

Once the structure is in place, protect it. Give each feature a small public API, use path aliases for cross-boundary imports, keep barrels to the boundaries, and enforce the rules with ESLint so they hold up under pressure. When even that isn't enough for a very large codebase, a formal methodology like Feature-Sliced Design is the natural next step.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading