Skip to content
ReactField

TopicsIntermediate

Project Standards

React project standards: folder structure, feature boundaries, naming, component layout, ESLint and Prettier, pre-commit hooks, CI and docs.

Mohamed ElshahawyPublished Apr 2026Updated Oct 2026
15 min read

What you'll learn

  • How to organize folders as a React app grows, from a flat src to feature folders
  • How to keep features independent and enforce it with a lint rule
  • Naming conventions and a component file layout the whole team can follow
  • A modern ESLint, Prettier, pre-commit and CI setup that applies the rules for you
  • Documentation habits that stay accurate as the code changes

Every team has standards, whether they are written down or not. When they live only in people's heads, they show up as review comments about import order, as arguments about where a file should go, and as a new developer asking the same questions everyone asked before them.

Written standards fix that. A new teammate can find their way around the codebase on day one. Code reviews talk about logic and behavior instead of formatting. And the hundred small "where does this go" decisions are made once, not in every pull request.

The best standards are the ones a tool enforces. This article covers what to agree on and how to make the computer check it for you.

A tidy filing cabinet next to a checklist, a ruler and a broom
Standards keep a codebase easy to find your way around, easy to review and easy to clean up.

Folder structure

There is no single correct folder structure, but there is a reliable way to arrive at a good one: start flat and let the structure grow out of the code.

When an app has a handful of files, put them all in src. Planning a deep structure upfront means guessing at features that do not exist yet, and the guess is usually wrong. Once you have ten or more files, patterns appear on their own, and you can group them in one of two ways:

  • By type: components/, hooks/, utils/. Easy to start with, but one feature ends up spread across many folders, and every folder keeps growing.
  • By feature: features/billing/, features/auth/, each with its own components, hooks and API calls. Code that changes together lives together, and deleting a feature means deleting one folder.

Most growing apps end up with a mix: feature folders for product code, and a few shared folders for the generic pieces every feature uses, such as buttons, form inputs and a configured HTTP client.

Three folder trees from left to right: a flat src with six files, the same files sorted into components, hooks and utils, and a src grouped into features/billing, features/auth and shared, highlighted in orange
Start flat, then group by feature once patterns appear. Grouping by type works for small apps but scatters a feature across folders.

Explore three common structures below. Select a folder or file to see what belongs in it.

A small app, based on the structure Josh Comeau describes (opens in a new tab). Each component gets its own folder, and shared helpers and hooks sit next to them. His React tutorials (opens in a new tab) are worth reading too.

Select a folder or file to see what belongs in it.

Frameworks add one more layer. Next.js uses the app/ (or pages/) folder for routing, and React Router or TanStack Router setups often have a routes/ folder. Keep those route files thin: they read params, load data and render components from your feature folders. The routing folder decides where a screen lives; the feature folders decide how it works.

Checkpoint

A DatePicker component is used by the billing feature and by the settings feature. Where should it live?

Feature boundaries

Feature folders only help if features stay separate. The moment settings imports a component from deep inside billing, the two are tied together, and refactoring one breaks the other.

Two rules keep the boundaries clear:

  1. Imports flow one way. Shared code (src/components, src/hooks, src/lib) can be used by everything but imports nothing from features. Features can use shared code. The app layer, meaning routes and providers, composes features. Never the other way around.
  2. Features do not reach into each other. Teams usually pick one of two styles:
    • Public entry files: each feature has an index.ts that exports only what other code may use, and everything outside the feature imports from @/features/billing, never from its inner folders.
    • No cross-feature imports: features never import each other at all. The route that needs both renders both. This is what bulletproof-react recommends, and it skips index.ts files because they can make tree shaking harder for Vite. The Bundle Size Optimization article covers that cost.

Try both styles below. Pick a file, pick something it imports, and switch between the two rule sets.

Try it: import boundary checker

Pick a file and something it wants to import. The checker applies the project's boundary rules and shows the lint error a blocked import would raise.

Rules

Code outside a feature imports it only through its index.ts.

Examples:

src/features/auth/components/LoginForm.tsx

import { InvoiceList } from '@/features/billing/components/InvoiceList'

Blocked

This reaches into the internals of billing. Export InvoiceList from src/features/billing/index.ts if other features really need it, and import it from there.

ESLint output

src/features/auth/components/LoginForm.tsx
  1:1  error  '@/features/billing/components/InvoiceList' import is restricted from being used by a pattern. Import billing through its public entry: '@/features/billing'.  no-restricted-imports

Project layers

The messages use the real formats of import/no-restricted-paths and ESLint's no-restricted-imports. The text after the first sentence is the custom message you write in your config, shown below.

Three stacked shelves labelled app, features and shared, with checked arrows pointing down between them. An upward arrow from shared and a sideways arrow from auth to billing are crossed out in orange
Imports flow down from the app to features to shared code. Any arrow pointing the other way is a lint error.

The rules only hold if a tool checks them. import/no-restricted-paths from eslint-plugin-import blocks imports between folders. This config implements the "no cross-feature imports" style:

eslint.config.js (feature boundaries)
import importPlugin from 'eslint-plugin-import'

export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    plugins: { import: importPlugin },
    settings: { 'import/resolver': { typescript: true } },
    rules: {
      'import/no-restricted-paths': [
        'error',
        {
          zones: [
            // Features stay independent: one zone per feature
            {
              target: './src/features/auth',
              from: './src/features',
              except: ['./auth'],
              message: 'Features cannot import from other features.',
            },
            {
              target: './src/features/billing',
              from: './src/features',
              except: ['./billing'],
              message: 'Features cannot import from other features.',
            },
            // Imports flow one way: shared, then features, then app
            {
              target: './src/features',
              from: './src/app',
              message: 'Features cannot import from the app layer.',
            },
            {
              target: ['./src/components', './src/hooks', './src/lib'],
              from: ['./src/features', './src/app'],
              message: 'Shared code cannot import from features or the app.',
            },
          ],
        },
      ],
    },
  },
]

The typescript resolver (from eslint-import-resolver-typescript) lets the rule understand @/ aliases. For the "public entry files" style, keep the last two zones and add ESLint's built-in no-restricted-imports to block deep imports:

eslint.config.js (public entry files)
'no-restricted-imports': [
  'error',
  {
    patterns: [
      {
        group: ['@/features/*/*'],
        message: 'Import a feature through its public entry, for example @/features/billing.',
      },
    ],
  },
],

If you want more control, eslint-plugin-boundaries lets you describe layers and allowed dependencies in one place. In a monorepo, Nx has the same idea built in, covered in Nx Introduction & Architecture.

Key takeaway

Decide which way imports may flow, then let a lint rule enforce it. Boundaries that only live in a wiki erode one pull request at a time.

Naming conventions

Good names make code searchable and predictable. The specific convention matters less than applying it everywhere, so pick one and write it down. A common set for React:

  • Components and their files: PascalCase, for example InvoiceList.tsx. For other files, pick camelCase (formatCurrency.ts) or kebab-case (format-currency.ts) and stick with it.
  • Named exports over default exports. The name stays the same everywhere it is imported, which makes renames and searches reliable. Framework files such as Next.js pages are the exception, because they require a default export.
  • Hooks start with use. This is not just style: React's lint rules and the React Compiler rely on the prefix to recognize hooks.
  • Booleans read as yes or no questions: isLoading, hasError, canEdit.
  • Event props start with on, the functions that handle them with handle: a component accepts onSelect and defines handleSelect.
  • Name things by what they hold, not their type: invoices, not data or list.
Names that need a second lookTSX
// src/components/invoice_list.tsx
export default function list({ data, loading, click }) {
const [show, setShow] = useState(false)
const check = data.length > 0
function doClick(item) {
click(item)
setShow(true)
}
// ...
}
Names that explain themselvesTSX
// src/features/billing/components/InvoiceList.tsx
export function InvoiceList({ invoices, isLoading, onSelect }) {
const [isDetailsOpen, setIsDetailsOpen] = useState(false)
const hasInvoices = invoices.length > 0
function handleSelect(invoice) {
onSelect(invoice)
setIsDetailsOpen(true)
}
// ...
}

The naming cheatsheet goes deeper, and Clean Code JavaScript covers naming along with function size and side effects.

Component file anatomy

When every component file follows the same order, you know where to look before you open it. Here is a layout that scales well:

A code editor window split into six numbered bands labelled imports, types, hooks, handlers, JSX and helpers, with the hooks band pinned and highlighted in orange
The same order in every file: imports, types, hooks, derived values, effects, handlers, then JSX.
src/features/billing/components/InvoiceList.tsx
// 1. Imports: packages, then @/ aliases, then relative files, then types
import { useEffect, useState } from 'react'
import { Button } from '@/components/Button'
import { formatCurrency } from '@/lib/formatCurrency'
import { useInvoices } from '../hooks/useInvoices'
import type { Invoice } from '../types'

// 2. Constants that never change between renders
const PAGE_SIZE = 20

// 3. Props type
type InvoiceListProps = {
  customerId: string
  onSelect?: (invoice: Invoice) => void
}

// 4. The component, as a named function
export function InvoiceList({ customerId, onSelect }: InvoiceListProps) {
  // 5. State and other hooks, always before any early return
  const { data: invoices = [] } = useInvoices(customerId)
  const [page, setPage] = useState(0)

  // 6. Values derived from props and state
  const visible = invoices.slice(0, (page + 1) * PAGE_SIZE)
  const hasMore = visible.length < invoices.length

  // 7. Effects
  useEffect(() => {
    document.title = `Invoices (${invoices.length})`
  }, [invoices.length])

  // 8. Event handlers
  function handleShowMore() {
    setPage((current) => current + 1)
  }

  // 9. Early returns, then JSX
  if (invoices.length === 0) return <EmptyState />

  return (
    <section>
      <ul>
        {visible.map((invoice) => (
          <li key={invoice.id}>
            <button type="button" onClick={() => onSelect?.(invoice)}>
              {invoice.number}: {formatCurrency(invoice.total)}
            </button>
          </li>
        ))}
      </ul>
      {hasMore ? <Button onClick={handleShowMore}>Show more</Button> : null}
    </section>
  )
}

// 10. Small components used only in this file
function EmptyState() {
  return <p>No invoices yet.</p>
}

A few details are worth calling out:

  • Use a plain function declaration instead of const InvoiceList: React.FC<Props> = .... It reads the same everywhere, and typing the props directly is all TypeScript needs.
  • Store as little state as possible. visible and hasMore are calculated during render, not kept in state. State Management Anti-Patterns explains why.
  • If the JSX gets long, move pieces into small components at the bottom of the file. Once a piece is used elsewhere, move it to its own file.

The order of hooks is not only a style choice. React identifies each hook by the order it is called in, so the number of hook calls must be the same on every render.

Predict the output

This list first renders with three invoices. Then the user deletes them all. What happens?

TSX
function InvoiceList({ invoices }: { invoices: Invoice[] }) {
  if (invoices.length === 0) return <EmptyState />

  const [page, setPage] = useState(0)
  // ...
}

Absolute imports

Relative paths like ../../../components/Button break whenever a file moves and are hard to read. A path alias lets every file import shared code the same way: @/components/Button.

Define the alias once in tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Next.js reads paths automatically, and create-next-app sets up @/* by default. Vite needs to be told to use it. Recent versions have a built-in option; older versions use the vite-tsconfig-paths plugin or a matching resolve.alias:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: { tsconfigPaths: true },
})

A useful convention: use @/ for imports that cross a folder boundary, and relative imports (./, ../) inside a feature. That way, a long relative path in a pull request is a hint that something is reaching across a boundary.

Linting and formatting

Two tools, two jobs:

  • ESLint finds problems: unused variables, missing hook dependencies, invalid ARIA, imports that cross a boundary.
  • Prettier formats code: quotes, semicolons, line length, indentation. It has very few options on purpose, so nobody has to argue about style.

ESLint 9 uses a single eslint.config.js file (the "flat config"). The old .eslintrc format is deprecated. A solid starting point for a React and TypeScript app:

eslint.config.js
import js from '@eslint/js'
import { defineConfig, globalIgnores } from 'eslint/config'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import eslintConfigPrettier from 'eslint-config-prettier/flat'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [js.configs.recommended, tseslint.configs.recommended],
  },
  reactHooks.configs.flat.recommended,
  jsxA11y.flatConfigs.recommended,
  // Last, so it can turn off rules that conflict with Prettier
  eslintConfigPrettier,
])

eslint-config-prettier turns off every ESLint rule that would fight with Prettier's formatting, so the two tools never disagree. The jsx-a11y rules catch common accessibility mistakes; Accessibility & Semantics explains what they look for.

Prettier needs only a small config file, and an .editorconfig keeps editors that do not use Prettier consistent:

.prettierrc
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100
}
.editorconfig
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true

Finally, have the editor run both tools on save, and commit the settings so everyone gets them:

.vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }
}

If you would rather have one tool, Biome combines a linter and a formatter and is much faster. It has fewer plugins than ESLint, so check that it covers the rules you depend on.

Automate the checks

Rules that depend on people remembering them will be skipped on a busy day. Put the checks in three places, each one catching what the previous one missed.

Four gates in a row joined by an orange line: an editor, a pre-commit hook, a CI checklist and a merge symbol
The editor fixes issues as you type, a pre-commit hook checks changed files, and CI checks everything before merge.

1. On commit, with husky and lint-staged. husky installs Git hooks for everyone who runs npm install, and lint-staged runs your tools on the files in the commit only, so the hook stays fast:

Terminal
npm install --save-dev husky lint-staged
npx husky init

husky init creates .husky/pre-commit. Replace its contents with one line:

.husky/pre-commit
npx lint-staged
package.json
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": "prettier --write"
  }
}

2. In CI, on every pull request. Hooks can be skipped with git commit --no-verify, and they only see the changed files. CI is the check nobody can skip:

package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "format:check": "prettier --check .",
    "test": "vitest run"
  }
}
.github/workflows/ci.yml
name: CI
on: [pull_request]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run format:check
      - run: npm test

Make the CI job a required check, so a pull request cannot be merged while it fails. Automated Testing covers what the test step should include.

3. In the pull request itself. A short template reminds authors of what the tools cannot check:

.github/pull_request_template.md
## What changed and why

## How to test it

## Checklist
- [ ] Works with keyboard only
- [ ] Docs or stories updated if behavior changed
- [ ] No new dependency without a reason in the description

Checkpoint

Code reviews keep getting comments about quote style and import order. What is the best fix?

Documentation that stays current

Everyone agrees good documentation is valuable. The hard part is keeping it accurate. The documentation that survives is the kind that lives next to the code and breaks loudly when it is wrong.

Types are documentation the compiler checks. With TypeScript, every function signature tells your teammates what goes in and what comes out, and their editor shows it on hover. Unlike a wiki page, it cannot silently go out of date.

Comment the why on shared code. For functions in src/lib and shared hooks, a short TSDoc comment appears in every editor tooltip:

src/lib/formatCurrency.ts
/**
 * Formats an amount stored in minor units (cents) as a currency string.
 *
 * @param amount - The amount in minor units, for example 1999 for 19.99.
 * @param currency - An ISO 4217 code such as "USD" or "EUR".
 * @example formatCurrency(1999, 'EUR') // "€19.99"
 */
export function formatCurrency(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount / 100)
}

Use Storybook for shared UI. Storybook renders each component in every state (loading, empty, error, disabled) in one browsable place. Designers and product managers can review components without running the app, and the stories double as visual tests. The Storybook for React tutorial is a good start.

Keep a short README. It should answer what a new developer asks on day one: how to install and run the app, which environment variables it needs, what each script does, and a one-paragraph map of the folders.

Record big decisions. When the team picks a state library or a folder structure, write a short architecture decision record in docs/adr/: the context, the decision and its consequences, in a few paragraphs. A year later, it answers "why did we do it this way?" without anyone having to remember.

A note on JSDoc: you can write type annotations in JSDoc comments and have TypeScript check them without a build step. Some library authors prefer that. For application code, writing TypeScript directly is usually simpler to read and maintain. Alex Harri's JSDoc as an alternative TypeScript syntax covers the trade-offs.

Agree on standards as a team

Standards only work if the team owns them. A few habits help:

  • Write them down in one place, such as a CONTRIBUTING.md at the root of the repository, and link to it from the README and the pull request template.
  • Automate first, document second. If a rule can be a lint rule, a Prettier option or a CI step, make it one. Keep the written document for the things tools cannot check, like folder structure and naming.
  • Start small. Adopt the formatter and a recommended lint config first. Add stricter rules one at a time, and fix existing violations in a dedicated pull request instead of mixing them into feature work.
  • Revisit them. If the team keeps disabling a rule with eslint-disable comments, talk about it. Either the rule is wrong for your codebase, or people need to understand why it exists.

Project standards checklist

  • Folders are grouped by feature once the app grows, with shared code in a few top-level folders.
  • Imports flow one way: shared, then features, then app.
  • A lint rule blocks imports between features or into their internals.
  • Naming conventions for files, components, hooks, booleans and handlers are written down.
  • Component files follow the same order, with hooks before any early return.
  • Shared code is imported through an @/ alias.
  • ESLint uses a flat config with TypeScript, React Hooks and jsx-a11y rules.
  • Prettier formats everything, and eslint-config-prettier is applied last.
  • Editors format and fix on save, with settings committed to the repository.
  • A pre-commit hook runs lint-staged.
  • CI runs type checks, lint, format checks and tests on every pull request, as a required check.
  • The README covers setup, scripts and the folder map, and big decisions are recorded.

Further reading

Conclusion

Project standards are a set of small decisions: where files go, which way imports flow, how things are named, what a component file looks like, and which tools check all of it. None of them is hard on its own. The value comes from making each decision once and applying it everywhere.

Start with what a tool can enforce: a formatter, a recommended lint config, a pre-commit hook and a CI job. Then write down the rest in one short document, and revisit it when the team or the codebase changes.

The key takeaway

Agree on a few clear rules, then hand them to the tools. Prettier, ESLint, a pre-commit hook and CI apply standards on every change, so code reviews can focus on what the code does.

Check your understanding

Test yourself6 questionsQuestion 1 of 6

You are starting a new app with six files. What folder structure should you set up?

Recap

What to remember from this article

Sections read

0

Questions

0

none answered yet

Quiz

0

not taken yet

Up next: Maintainable Architecture · 8 min

Continue