Claude Fable 5.1 & GPT-6 Astra packages are live

Folder Structure

Free

Organising a frontend codebase — colocation by feature, import boundaries enforced by tooling, naming, and the shared layer that becomes a dumping…

209 lines7.7 KB Qwen Frontend
targetModels
Qwen3.8-MaxQwen3.8-Flash-NextQwen3.8-27BQwen3.8 FamilyFuture Qwen Models
name
folder-structure
category
Frontend
description
Organising a frontend codebase — colocation by feature, import boundaries enforced by tooling, naming, and the shared layer that becomes a dumping ground.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for Qwen: scripts/model-profiles.json -->

#Task boundary

  1. Implement only what the task names; no extra abstractions or files.
  2. English-only comments and identifiers.
  3. Stop when the checklist passes.

#Purpose

Rules for laying out a frontend codebase. Structure is a communication tool: it tells a new contributor where a change belongs, and it makes an accidental dependency visible in a diff.

The single decision that matters: organise by feature, not by file type.


#Colocate by feature

bash
src/
  features/
    orders/
      components/OrderTable.tsx
      hooks/useOrders.ts
      api/orders.ts
      schemas.ts
      types.ts
      index.ts              # the public surface of this feature
    checkout/
    auth/
  shared/
    ui/                     # Button, Input — no feature knowledge
    lib/                    # formatMoney, cn — pure utilities
    hooks/                  # useDebouncedValue — generic
  app/                      # routes, layouts, providers

Compare with organising by type (components/, hooks/, utils/ at the top level): a single feature change touches four distant directories, related files are never adjacent, and deleting a feature means hunting through every folder.

Colocation means a feature is one directory. You can read it, review it, and delete it in one place.

Test files, styles and stories live beside the component they cover (OrderTable.tsx, OrderTable.test.tsx) — a test in a parallel __tests__ tree is the file most likely to be forgotten when the component moves.


#Enforce the boundaries

A structure nobody enforces reverts to a graph within a quarter.

js
// eslint.config.js — features may not import each other's internals
{
  rules: {
    "import/no-restricted-paths": ["error", { zones: [
      { target: "./src/features/*/!(index.ts)", from: "./src/features", except: ["./index.ts"] },
      { target: "./src/shared", from: "./src/features" },   // shared must not know features
    ]}],
  },
}

Three rules, all machine-checkable:

  1. Cross-feature imports go through index.ts. Reaching into features/orders/hooks/useOrders from features/checkout couples them to an internal path.
  2. shared/ never imports from features/. The moment it does, "shared" means "everything", and the dependency graph is a cycle.
  3. No circular imports (eslint-plugin-import no-cycle).

Add dependency-cruiser or eslint-plugin-boundaries for a stricter layered model when the codebase warrants it.


#Promote to shared/ on the third use

shared/ is where structure goes to die if anything can enter it.

  1. Something used by one feature lives in that feature.
  2. Used by two? Duplicate it, or leave it where it is. Premature abstraction over two similar-looking cases produces a component with seven boolean props.
  3. Used by three, with the same meaning? Promote it — and give it its own tests.

Never create utils.ts, helpers.ts, common/ or misc/. A name that does not say what is inside guarantees unrelated things accumulate there. Name by domain: shared/lib/currency.ts, shared/lib/dates.ts.

shared/ui holds presentational components with no business knowledge. A Button that knows about orders is not shared.


#Naming and imports

ThingConvention
Component filesPascalCase.tsx, matching the exported component
HooksuseThing.ts — the prefix drives lint rules
Utilities, configkebab-case.ts
Directorieskebab-case
TypesPascalCase; colocated unless shared
TestsThing.test.tsx beside the source

Pick one export style and hold it: default exports for route/page components (frameworks expect them), named exports everywhere else. Named exports refactor better and cannot be imported under a different name by mistake.

Use path aliases (@/features/orders) rather than ../../../. Relative paths break on every move and hide how far a module is reaching.

Order imports consistently — external, then aliased internal, then relative — enforced by eslint-plugin-import so it never appears in a diff.

json
// tsconfig.json — one alias root keeps imports short and moves cheap
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  }
}

Mirror the alias in the bundler (vite.config.ts resolve.alias, or webpack resolve.alias) — TypeScript path mapping affects type checking only, and a missing bundler alias produces a build that type-checks and then fails to resolve at runtime.


#Signals to restructure

  1. A directory with more than ~15 files is usually two features.
  2. A file over ~300 lines is usually two files.
  3. A "feature" imported by every other feature is infrastructure — move it to shared/ or app/.
  4. A cycle between features means one concept has been split across both.
  5. A shared/ directory growing faster than features/ means the promotion rule is not being applied.

Restructure when the signal appears, not on a schedule, and do it as its own commit — a move mixed with a behaviour change is unreviewable.


#Anti-patterns

Anti-patternWhy it failsFix
Top-level components/, hooks/, utils/One change touches four directoriesOrganise by feature
Tests in a parallel __tests__ treeForgotten when the source movesColocate
Cross-feature deep importsCouples to internal pathsImport via index.ts
shared/ importing from features/Dependency cycle; "shared" means everythingOne-way dependency
Circular importsUndefined initialisation order; hard to reason aboutno-cycle lint rule
utils.ts / helpers.ts / misc/Unrelated code accumulatesDomain-named modules
Promoting to shared/ on first reusePremature abstraction with boolean propsWait for the third use
Shared components with domain knowledgeNot reusable; drags features alongKeep shared/ui presentational
Boundaries documented but not enforcedReverts to a graph in a quarterLint rules in CI
Deep relative importsBreak on every movePath aliases
Mixed export conventionsInconsistent imports; refactors miss casesOne rule, linted
Restructuring mixed with behaviour changesUnreviewable diffSeparate commits
Directories that grow without limitHides that it is two concernsSplit at the signal

#Checklist

  • Code is organised by feature, not by file type
  • Each feature directory contains its components, hooks, API and schemas
  • Tests, styles and stories are colocated with their source
  • Each feature exposes a public surface through index.ts
  • Cross-feature imports go through that surface only, enforced by lint
  • shared/ never imports from features/
  • Circular imports are blocked in CI
  • Code is promoted to shared/ only on the third genuine use
  • No utils, helpers, common or misc modules exist
  • shared/ui components carry no business knowledge
  • Naming conventions are consistent and linted
  • Export style is consistent across the codebase
  • Path aliases replace deep relative imports
  • Import order is enforced automatically
  • Restructuring lands as its own commit