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.

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.

Explore three common structures below. Select a folder or file to see what belongs in it.
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?
Pick an answer, or press A, B, C on your keyboard.
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:
- 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. - Features do not reach into each other. Teams usually pick one of two styles:
- Public entry files: each feature has an
index.tsthat 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.tsfiles because they can make tree shaking harder for Vite. The Bundle Size Optimization article covers that cost.
- Public entry files: each feature has an
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.
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.

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:
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:
'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 withhandle: a component acceptsonSelectand defineshandleSelect. - Name things by what they hold, not their type:
invoices, notdataorlist.
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:

// 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
functiondeclaration instead ofconst InvoiceList: React.FC<Props> = .... It reads the same everywhere, and typing the props directly is all TypeScript needs. - Store as little state as possible.
visibleandhasMoreare 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?
function InvoiceList({ invoices }: { invoices: Invoice[] }) {
if (invoices.length === 0) return <EmptyState />
const [page, setPage] = useState(0)
// ...
}Pick an answer, or press A, B, C, D on your keyboard.
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:
{
"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:
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:
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:
{
"semi": false,
"singleQuote": true,
"printWidth": 100
}root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = trueFinally, have the editor run both tools on save, and commit the settings so everyone gets them:
{
"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.

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:
npm install --save-dev husky lint-staged
npx husky inithusky init creates .husky/pre-commit. Replace its contents with one line:
npx lint-staged{
"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:
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format:check": "prettier --check .",
"test": "vitest run"
}
}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 testMake 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:
## 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 descriptionCheckpoint
Code reviews keep getting comments about quote style and import order. What is the best fix?
Pick an answer, or press A, B, C on your keyboard.
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:
/**
* 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.mdat 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-disablecomments, 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-prettieris 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
- Maintainable React Architecture for UI, state, service and converter boundaries inside a feature
- Project structure (opens in a new tab) in bulletproof-react
- Delightful React File/Directory Structure (opens in a new tab) by Josh Comeau
- React Folder Structure in 5 Steps (opens in a new tab) by Robin Wieruch
- Naming cheatsheet (opens in a new tab)
- Clean Code JavaScript (opens in a new tab)
- Configuration files (opens in a new tab) in the ESLint docs
- Getting started (opens in a new tab) with typescript-eslint
- import/no-restricted-paths (opens in a new tab) and eslint-plugin-boundaries (opens in a new tab)
- Integrating Prettier with linters (opens in a new tab)
- Husky (opens in a new tab) and lint-staged (opens in a new tab)
- Architectural decision records (opens in a new tab)
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
You are starting a new app with six files. What folder structure should you set up?
Pick an answer, or press A, B, C on your keyboard.
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