Skip to content
    All posts
    Frontend

    The Frontend Folder Structure That Survived Three Rewrites

    Organise by feature, not by file type. Sounds obvious; almost nobody does it, and the cost compounds quietly.

    3 min readby

    Every framework tutorial ships the same layout: components/, services/, utils/, types/. It's fine for twelve files. At two hundred, it means every feature is smeared across five directories and no directory tells you anything about what the app does.

    The tell

    Open a codebase and look at the top-level folders. If they're components, hooks, services, types, you're looking at an app organised by what things *are*. You can't tell whether it's a hospital system or a shopping cart.

    Now consider the practical version of that cost. A ticket says "change how invoice due dates are calculated." Where do you look? services/invoice.service.ts, probably. Also utils/date.ts. Also components/InvoiceRow.tsx. Also types/invoice.ts. Four folders, and you found them by memory or by grep, not by structure.

    What I use instead

    text
    src/
      features/
        invoices/
          components/       # only used inside invoices
          api.ts            # every invoice endpoint
          types.ts
          utils.ts
          hooks.ts
          index.ts          # the public surface
        employees/
        payroll/
      shared/
        ui/                 # Button, Input, Dialog — no domain knowledge
        lib/                # formatDate, cn, http client
        hooks/
      app/
        routes.tsx
        providers.tsx

    Now the invoice ticket is one folder. Everything about invoices is inside it. The change is reviewable, the blast radius is visible, and a new developer can be told "you own invoices" and it means something.

    The two rules that keep it honest

    Rule 1: features import from shared, never from each other

    The moment features/payroll imports from features/employees/components/EmployeeAvatar, the boundary is gone and you're back to a tangle with extra folders. When two features need the same thing, promote it to shared/.

    Enforce this with lint rules rather than good intentions — import/no-restricted-paths in ESLint, or Angular's @nx/enforce-module-boundaries if you're in an Nx workspace. A rule that isn't enforced isn't a rule, it's a preference, and preferences lose to deadlines.

    Rule 2: an index.ts is a public API

    ts
    // features/invoices/index.ts
    export { InvoiceList } from './components/InvoiceList';
    export { useInvoices } from './hooks';
    export type { Invoice, InvoiceStatus } from './types';
    // InvoiceRow, calculateTax, and the raw API client stay private

    Anything not exported is an implementation detail you can refactor freely. This is what makes the structure hold up over time: the surface area you must not break is explicit and small, and everything else is yours to change.

    What belongs in shared/ui

    One test: does it know anything about your domain? A Button doesn't. An InvoiceStatusBadge does — it knows invoices have statuses and which colours they map to. That belongs in the feature, even though it's visually generic.

    This is where most "shared component libraries" rot. Someone puts InvoiceStatusBadge in shared/ui, then another feature needs a slightly different status set, and the component grows a variant prop, then a mode prop, and eighteen months later it's a 300-line component with a config object that nobody can safely modify.

    Colocate tests, styles, and stories

    InvoiceList.tsx, InvoiceList.test.tsx, InvoiceList.stories.tsx — same directory. A parallel __tests__/ tree that mirrors your source tree is pure overhead: renaming a component means editing two paths, and moving a feature means moving two subtrees that must stay in sync.

    Don't do this on day one

    For the first few weeks of a project, flat is genuinely better. You don't know what the features are yet, and inventing boundaries before you understand the domain produces worse boundaries than inventing none.

    The signal to restructure is when you notice you're scrolling to find files, or when a code review touches five directories to do one thing. That's usually somewhere around 30–50 components. Restructure then, when the domain has told you what the seams are.

    ArchitectureReactAngularMaintainability

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.