Skip to main content
Back to module
Front-End Engineering · Front-End Architecture

Structuring a front-end codebase

Reading12mLesson 1 of 3 in this module

Organise features, components and utilities so a growing app stays easy to change.

Why it matters

A small React app can live in one src/ folder with a handful of files. A real product cannot. After a year it has dozens of pages, hundreds of components, several API clients and a team of people changing it at the same time. When nobody can tell where a piece of code belongs, people copy it instead of reusing it, imports turn into a tangle, and every change risks breaking something far away.

Structure is not about a perfect folder tree. It is about making three questions easy to answer: where does this code live, what is it allowed to depend on, and what can I delete when a feature goes away?

The core idea: group by feature, share deliberately

There are two common ways to organise a front-end.

By type puts all components in components/, all hooks in hooks/, all API calls in api/. It is fine for a tutorial, but a single feature such as "checkout" ends up spread across six folders.

By feature keeps everything one feature needs together, and only promotes code to a shared folder when a second feature actually needs it:

src/
  app/                 # routing, providers, app-wide layout
  features/
    checkout/
      components/      # CheckoutForm.tsx, OrderSummary.tsx
      hooks/           # useCart.ts
      api.ts           # fetchCart, placeOrder
      types.ts         # Cart, LineItem
      index.ts         # the feature's public API
    courses/
      ...
  components/ui/       # Button, Modal, Input: no business logic
  lib/                 # apiClient.ts, formatDate.ts, logger.ts
  test/                # test helpers and fixtures

Most mid-size teams end up with a hybrid like this: features own their code, and a small shared layer holds design-system components (components/ui) and framework-free utilities (lib).

Rules that keep it healthy

Folders alone do not stop the tangle. A few dependency rules do most of the work:

  1. Dependencies point inward. features/* may import from components/ui and lib. components/ui and lib must never import from a feature. A Button that imports the cart is no longer reusable.
  2. Features talk through their public API. Other code imports features/checkout (its index.ts), not features/checkout/hooks/useCart. That lets the checkout team move files without breaking everyone else.
  3. UI components do not fetch. Keep data loading in hooks or page-level code and pass data down as props. Pure components are easier to test, reuse and show in a component library.
  4. Utilities are plain functions. formatPrice(cents, currency) has no React, no fetch and no global state, so it can be unit tested in milliseconds.
  5. Colocate tests and styles with the code they cover (or in a mirrored test/ folder if your framework would treat test files as routes, as Next.js does under pages/).

You can enforce rule 1 with lint rules such as import/no-restricted-paths in ESLint, so a reviewer does not have to catch it by eye.

Worked example: where does it go?

Your team adds a "Save for later" button to course cards.

  • The heart icon button itself is generic. It goes in components/ui/IconButton.tsx (or reuses one that is there).
  • The logic that calls POST /api/bookmarks and updates the list is bookmark-specific. It goes in features/bookmarks/api.ts and features/bookmarks/useBookmark.ts.
  • The course card composes both. It lives in features/courses/components/CourseCard.tsx and imports useBookmark from features/bookmarks (the public API).
  • A helper that pluralises "1 lesson / 3 lessons" is framework-free. It goes in lib/pluralize.ts with a test next to it.

Six months later the product team removes bookmarks. You delete features/bookmarks/, fix the one import in CourseCard, and you are done. That is the payoff.

Naming and file size

  • One component per file, named after the component: OrderSummary.tsx exports OrderSummary.
  • Prefer named exports for components and utilities; they make renames and search easier.
  • When a file passes a few hundred lines, look for a seam: a sub-component, a custom hook, or a pure helper you can pull out.
  • Use path aliases (@/lib/formatPrice) instead of ../../../lib/formatPrice, configured in tsconfig.json and your bundler.

Common mistakes

  • A giant utils.ts. Everything ends up there and everything imports it. Split by purpose: formatDate.ts, currency.ts, url.ts.
  • Premature sharing. Moving code to components/ui after one use creates abstractions that fit nobody. Wait for the second real use.
  • Business logic in shared UI. A Button that knows about user roles has quietly become a feature component.
  • Reorganising everything at once. Restructure feature by feature in small pull requests, not in one rename that conflicts with every open branch.
  • Deep nesting. Four levels of folders make paths long and imports fragile. Two or three levels are usually enough.

Quick recap

  • Group code by feature and promote it to a shared layer only when a second feature needs it.
  • Keep dependencies pointing inward: features use ui and lib, never the other way round.
  • Give each feature a small public API through an index.ts.
  • Keep UI components presentational and utilities framework-free so both are easy to test.
  • Enforce the rules with lint, and restructure in small steps.

Try it

Open the Do tab and answer the quiz on where code belongs. Then look at a project you have built, sketch its current tree, and mark one file that is in the wrong place.

Also in this lesson: