@CodeWithSeb
Published on
48 min read

From Components to Systems: Scalable Frontend Architecture with Atomic Design + Feature Slices

Combining Atomic Designs UI methodology with Feature-Sliced Designs domain focus yields a modular, scalable React architecture. This guide explores how to balance reusable atomic components with clear feature-based boundaries, covering common pitfalls, a case study, best practices (SOLID, DRY, KISS), and mistakes to avoid for maintainable frontends

Scaling a React application from a simple prototype to a large, feature-rich product can be challenging. Many teams start with a “flat” project structure—dumping all components in a single folder or mixing business logic and UI code together. Over time, this approach leads to bloated folders, a chaotic shared directory, and tangled dependencies between unrelated parts of the app. The result is often a brittle codebase where a small change in one component can unexpectedly break functionality in another.

To address these pain points, frontend architects have developed Atomic Design and Feature-Sliced Design as methodologies for organizing code. Atomic Design breaks the user interface into a hierarchy from atoms to pages, promoting reusability and consistency in UI components. Feature-Sliced Design (FSD), on the other hand, organizes the project by business domains or features rather than technical layers, making it easier to maintain and scale complex logic. Each approach solves part of the puzzle: Atomic Design excels at modular UI, while FSD enforces clear domain boundaries. In this article, we’ll explore how combining Atomic Design and Feature Slices can yield a highly modular, scalable frontend architecture – one that maintains both UI consistency and separation of concerns as your React app grows.

The central idea is to design systems, not just pages or components. By the end, you’ll understand how to structure a React+TypeScript project so that your smallest UI pieces (buttons, inputs, etc.) fit into larger feature modules, like pieces of a well-organized puzzle. We’ll discuss the pitfalls of traditional file structures, recap the core principles of Atomic Design and Feature-Sliced Design, walk through an example of building a scalable dashboard, and conclude with best practices and common mistakes to avoid. Let’s dive in!

The Problem with Flat Architectures

In a "flat" or traditionally layered architecture, you might organize files primarily by type or technical concern. For example, all React components live in a components/ folder, services in services/, utilities in utils/, and so on. This initially seems logical, but as the codebase expands it can lead to serious issues:

Overcrowded Folders: A global components/ directory becomes a grab-bag of everything from basic UI elements to complex page-specific components. It's hard to tell which components belong to which feature or page. Onboarding new developers is slow because nothing "screams" the app's purpose – the structure doesn't convey what the app does.

Shared Chaos: Without clear guidelines, many components end up in a generic shared/ or common/ folder. Over time, this folder balloons with unrelated bits of code – a dumping ground for anything deemed reusable. It becomes the new monolith, where a change to one "shared" component can inadvertently impact features all across the app.

Tight Coupling: In a flat structure, features often reach into each other's internals. For instance, Component A in one part of the app might directly import Component B from a completely different module just because it's convenient. These hidden dependencies create a fragile web – changes ripple unpredictably through the system.

Long Import Chains: You start seeing import statements with many ../ segments, navigating tangled paths. This is a red flag that code is not where a reader expects it. Perhaps a page component is importing a low-level utility from deep inside another feature's folder. Such spaghetti imports indicate unclear boundaries and make maintenance harder.

Let's illustrate a typical poorly-structured React project:

src/
├── components/
│   ├── Button.jsx           # Generic button component
│   ├── Navbar.jsx           # Navigation bar (used in header)
│   ├── ProfileForm.jsx      # Form for user profile (specific to Profile page)
│   └── ...                  # (dozens more components)
├── pages/
│   ├── HomePage.jsx         # Uses Navbar, etc.
   └── ProfilePage.jsx      # Uses ProfileForm, etc.
├── services/
│   ├── api.js               # AJAX calls, e.g. getUserProfile
│   └── auth.js              # Authentication logic
├── utils/
│   ├── formatDate.js        # Utility functions
│   └── validateEmail.js
└── ...                      # (other global stuff)

In this structure, feature-related code is scattered: the Profile page's UI (ProfileForm.jsx) sits in components/ alongside unrelated components, while data fetching for the profile might be in services/api.js. There's no clear boundary around the "User Profile" concept – its UI, logic, and data are all in different places. As the app grows, the components/ folder keeps expanding, and developers must mentally map which files relate to which feature. This often leads to "monolithic" files and convoluted dependencies when implementing new features.

Ultimately, a flat architecture doesn't scale well. It becomes a Big Ball of Mud (an infamous architecture antipattern) where "everything is everywhere," and the codebase lacks modular structure. We need a better way to organize our frontend – one that provides visual consistency and reusability and maintains clear separation by feature. This is where combining Atomic Design and Feature-Sliced Design comes in.

Atomic Design Recap (UI Layer)

Atomic Design, introduced by Brad Frost, is a methodology for constructing UIs from the ground up, using a hierarchy of component types. The levels of Atomic Design (from smallest to largest) are: atoms, molecules, organisms, templates, and pages. Let's briefly recap each:

Atoms: The basic building blocks of the UI. These are primitive, indivisible components like buttons, inputs, icons, or text fields. Atoms usually have no business logic; they're often just styled HTML elements (e.g. a <Button> component).

Molecules: Small composed components, made by combining atoms. A molecule achieves a relatively simple function. For example, a SearchField molecule might consist of an Input atom and a Button atom together, or a form label paired with an input field. Molecules typically handle simple UI logic (like input state) but not complex behaviors.

Organisms: Larger, more complex UI sections composed of multiple molecules and/or atoms functioning together. An organism could be something like a navigation bar, a user card, or a multi-field form. Organisms may contain local state or interact with application logic, but they represent a distinct piece of the interface (e.g. a header bar with logo, search form, and profile menu).

Templates: Page-level layouts that provide structure for organisms and molecules. A template is like a wireframe or skeleton – for example, a dashboard layout template might define a sidebar, header, and content area without specifying the actual content. Templates arrange components but are filled with real data later.

Pages: The concrete pages/screens of the app, where templates get populated with specific content and data. A page is typically tied to a route (e.g. ProfilePage) and composes templates, organisms, etc. with real props or state. Pages represent the end-user view and often integrate with data fetching to fetch the necessary content.

Visually, you can imagine the Atomic Design hierarchy as a pyramid of reusability: Atoms → Molecules → Organisms → Templates → Pages. Lower levels (atoms, molecules) are highly reusable across the app, while upper levels (pages) are more context-specific.

Atomic Design brings order to the UI layer by ensuring you build interfaces systematically. Instead of creating ad-hoc components for every screen, you identify common design elements (atoms) and reuse them to build consistent higher-level components. This yields a shared vocabulary between designers and developers (everyone knows what an "atom" vs an "organism" is) and a library of components that can be mixed and matched. The rule is that each layer should only compose elements from the same layer or below (e.g. organisms are made of molecules/atoms, templates contain organisms, etc.), never the reverse. This one-way assembly line prevents circular dependencies and enforces clear separation of concerns in the UI.

In practice, applying Atomic Design in a project often means structuring your components folder into subfolders for each layer. For example, you might have:

shared/ui/
├── atoms/
│   ├── Button.tsx
│   ├── Input.tsx
│   └── Icon.tsx
├── molecules/
│   ├── SearchField.tsx      # uses Button + Input
│   └── FormField.tsx        # e.g., label + input combination
├── organisms/
│   ├── LoginForm.tsx        # uses multiple molecules/atoms
│   └── NavBar.tsx           # uses Icon, buttons, etc.
├── templates/
│   └── MainLayout.tsx       # uses NavBar, placeholders for content
└── pages/
    └── HomePage.tsx         # uses MainLayout, organisms, etc.

In the context of a React+TypeScript app, atoms and molecules are great candidates for the shared/ui directory – a centralized place for purely presentational components that can be reused in any feature or page. Examples: a generic Button, Input, or Spinner atom; a Modal organism that many pages might use. By placing them in shared/ui, you avoid duplicating these basics in multiple features.

However, not every UI component should be global. A key lesson of combining Atomic Design with feature-based thinking is to only share what truly needs to be shared. If a component is very specific to one feature's UI, it can live closer to that feature (we'll see how, in the next sections). Atomic Design can still guide you at the feature level – e.g. within a complex feature, you might have feature-specific atoms, molecules, etc. – but you won't prematurely pollute the global shared space.

To clarify what goes in shared UI vs. feature/entity UI:

  • Put an atomic component in shared/ui only if it's generic and used in many places. For example, a styled Button or TextInput that appears across the app is a good candidate for shared/ui/atoms. A modal dialog or date picker used by multiple features might go in shared/ui/organisms.

  • If a component is tied to a specific domain or feature, keep it local to that context. For example, a UserAvatar component (showing a user's profile picture) is visually an atom, but conceptually it belongs to the "User" domain – you might place it in entities/user/ui (under the user entity module). Similarly, a ProfileDetails molecule that displays a user's name and email might live in the user-profile feature folder rather than in global shared. This way, changes to that component are localized to the feature/domain.

  • Templates and pages often align with the routing/pages layer of the app. For instance, Next.js encourages a pages/ directory for each route; within those page components, you can use your templates and organisms. If you have common layout templates (e.g. MainLayout, AdminLayout), you might keep those in shared/ui/templates or a dedicated layouts/ folder. Feature-specific pages (like a settings page only for user profile) would be in the feature or in the pages layer referencing that feature's components.

Let's look at a quick code example of atomic components to solidify the idea:

// shared/ui/atoms/Button.tsx
import { ButtonHTMLAttributes, FC } from 'react'

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>

export const Button: FC<ButtonProps> = ({ children, ...props }) => (
  <button {...props}>{children}</button>
)
// shared/ui/atoms/Input.tsx
import { InputHTMLAttributes, FC } from 'react'

type InputProps = InputHTMLAttributes<HTMLInputElement>

export const Input: FC<InputProps> = (props) => <input {...props} />
// shared/ui/molecules/SearchForm.tsx
import { FC, useState } from 'react'
import { Input } from '../atoms/Input'
import { Button } from '../atoms/Button'

export const SearchForm: FC<{ onSearch: (query: string) => void }> = ({ onSearch }) => {
  const [query, setQuery] = useState('')

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    onSearch(query)
  }

  return (
    <form onSubmit={submit}>
      <Input value={query} placeholder="Search..." onChange={(e) => setQuery(e.target.value)} />
      <Button type="submit">Go</Button>
    </form>
  )
}

In this snippet, Button and Input are atoms (reusable, no special logic), and SearchForm is a molecule composed of those atoms. Notice how simple and focused each component is. By keeping atoms dumb (purely presentational) and molecules only as smart as they need to be (here the SearchForm holds a bit of state for the input), we adhere to the Single Responsibility principle. The SearchForm doesn't do a data fetch itself; it merely collects user input and passes it out via onSearch prop. In a larger app, a higher-level component or feature logic would handle what happens with that search query.

Atomic Design by itself gives us a structured UI toolkit. But it doesn't tell us where to put state management, data fetching, or how to group components by feature. In fact, one critique of Atomic Design is that it "does not provide a clear level of responsibility for business logic," which can lead to that logic being scattered across components of various sizes. This is where Feature-Sliced Design comes in – to handle the bigger picture of application structure, while we continue to use Atomic principles for the UI parts.

Feature-Sliced Design Overview (Domain Layer)

Feature-Sliced Design (FSD) is an architectural methodology for frontend projects that organizes code by features and business domains rather than by technical roles. It's essentially applying the concept of separation of concerns at a higher level: each slice of the app contains everything for a given feature, from UI to state to API calls. This method is inspired by ideas from domain-driven design, but tailored to frontend and React projects. The core benefit is avoiding the "monolithic layers" problem by slicing the code vertically by feature, so that each feature is a self-contained module.

FSD defines a few layers and conventions for structuring a frontend codebase:

App Layer: The top level of the application, responsible for global configuration and initialization. This includes things like the application entry point, routing setup, providers for context or state (Redux Provider, React Query client, ThemeProvider, etc.), and global styles. Essentially, app/ is where you bootstrap your React app.

Pages Layer: Each page (typically corresponding to a route or a distinct view in the app) gets its own module in pages/. A page composes features and UI components to form the screen. Think of pages as orchestrators: they don't contain the heavy logic themselves, but they pull together the necessary pieces (features, layouts, entities) for display. In FSD, pages are allowed to import from features, entities, and shared layers to assemble the final UI.

Features Layer: The features are the heart of the application's functionality. A feature represents a self-contained user-facing capability or section of the app. For example: user authentication, user profile editing, shopping cart, search bar, comment widget – each could be a feature. A feature folder (e.g. features/user-profile/) will include everything related to that feature's operation: its UI components, state management (like Redux slice or Zustand store or context), and any specific business logic or utilities. Features are designed to be reusable in the sense that you could include the same feature on multiple pages if needed. They should expose a clear public API (usually via an index.ts) to be used by pages or other upper layers.

Entities Layer: Entities are abstractions of your domain models – the core business objects that multiple features might revolve around. For instance, User, Product, Order, Article, Task could be entities. An entities/<entity>/ folder typically contains the data structures (TypeScript interfaces or types for that entity), data fetching logic for that entity (API calls, perhaps a small wrapper hook like useUser), and maybe minimal UI components to represent the entity (e.g. a UserAvatar component, or ProductImage component). Entities are meant to be reusable across features; they usually don't handle complex interactions on their own, but provide building blocks.

Shared Layer: The shared layer holds code that is generic and not tied to a specific feature or entity. This includes things like utility functions (shared/lib), constants, and often UI components that are used widely (which we mapped to shared/ui for Atomic Design atoms and molecules). You might also have a shared/api for generic API clients, or shared/config for app-wide configuration. The key is that nothing in shared/ should contain business-specific logic – if it does, it probably belongs in features or entities instead. Shared is like a toolbox accessible to all parts of the app, but it should be curated carefully to avoid becoming a dumping ground.

In some versions of FSD, you might encounter additional layers such as "processes" or "widgets" (sometimes also called "widgets" or similar). These are more granular or optional layers:

  • Processes: higher-level flows that span multiple pages or features (for example, a complex checkout flow that involves multiple steps and features could be a process).

  • Widgets: a concept some use to denote UI parts that are bigger than organisms but not full pages – essentially, mini-application sections that could be composed on a page. (The terminology varies; some people use "widgets" interchangeably with features or organisms.)

If this seems abstract, let's ground it with an example folder structure emphasizing layers and slices:

src/
├── app/
│   ├── providers/           # e.g., Context providers, global store setup
│   ├── router.tsx           # Routing configuration
│   └── App.tsx              # Application root component
├── processes/
│   └── onboarding/          # (Optional) a multi-step onboarding flow as a process
├── pages/
│   ├── DashboardPage.tsx    # Assembles features for the dashboard route
│   └── ProfilePage.tsx      # Assembles features for the profile route
├── features/
│   ├── user-profile/        # Feature: user profile display & editing
│   ├── task-list/           # Feature: task list widget
│   └── notifications/       # Feature: notification bell and feed
├── entities/
│   ├── user/                # Entity: user data (used by user-profile, notifications, etc.)
│   └── task/                # Entity: task data (used by task-list feature)
└── shared/
    ├── ui/                  # Design system components (atoms, molecules, etc.)
    ├── lib/                 # Utilities, e.g., date formatting
    └── config/              # Global config (e.g., API endpoints, feature flags)

Slices: Within the features and entities layers, each subfolder (e.g. user-profile or task) is a slice representing a specific domain or feature area. Each slice is meant to be self-contained – one slice should not directly depend on another slice at the same layer. This is a critical rule: code in one feature should not directly import code from another feature; same for entities. Why? To keep coupling low. If feature A needs something from feature B, that something likely belongs in a lower layer (perhaps an entity or shared) so both can use it without depending on each other. Enforcing this rule means you can work on or refactor a single feature in isolation with less fear of breaking others.

Each slice (feature or entity) is further organized into segments by purpose. Common segment folders include:

  • ui: All visual components for this feature/entity (could be further broken into atoms, molecules, etc., or just a components folder).

  • model: The state management and business logic – for example, Zustand store, MobX store, or Redux slice, as well as domain-specific functions. Also types/interfaces for this slice.

  • api: Functions for network requests related to this feature or entity (e.g. fetchUserProfile or updateUserProfile in the user-profile feature, or fetchUserById in the user entity). API segment might also include API response types and mappers.

  • lib: Any feature-specific libraries or utilities. Perhaps a custom hook used only in this feature, or a small helper function.

  • config: Configuration constants or feature flags for that feature.

Not every slice will have all of these segments, just as needed. For example, an entity like user might have model (with a User type and maybe a context or store), api (for user-related HTTP calls), and ui (like a UserAvatar component). A simple feature might only have ui and model. The goal is to group by purpose so that within a slice, you know where to put things and where to find things. The uniform structure across slices makes the project predictable and easier to navigate.

Dependency Direction: FSD prescribes a general direction of dependency between layers:

  • Higher-level layers (app, pages) can depend on anything below (features, entities, shared).

  • Features can depend on entities and shared, but not on other features or on pages (and ideally not on app layer, aside from using some context providers).

  • Entities can depend on shared (for base utilities or UI), but typically not on features (entities should be lower-level than features).

  • Shared should be the lowest-level, with no dependencies on app/feature-specific code. (For instance, a shared util should not import from an entity or it wouldn't be "shared" anymore.)

This creates a one-way street that helps prevent circular dependencies and keeps the business logic localized. It's analogous to how Atomic Design ensures atoms don't depend on molecules or organisms – here, features shouldn't depend on other features at the same layer, and so forth.

In summary, Feature-Sliced Design gives us a scalable structure: we can add new features or entities as new slices without disturbing existing ones, and we can reason about the app in terms of business domains (user, tasks, profile, etc.) rather than low-level technical pieces. Next, we'll see how to integrate the Atomic Design principles into this sliced structure.

Combining the Two Approaches

Now that we've covered both Atomic Design and Feature-Sliced Design separately, the real power comes from combining them. The goal is to get the best of both worlds: a hierarchical, reusable UI component library and a feature-oriented code separation. Here's how they fit together:

  1. Use Atomic Design within slices: Each feature or entity will have its own ui segment. We can apply the atomic categorization inside those ui folders. That means, if a feature has multiple components of varying complexity, you can organize them into sub-folders like atoms, molecules, organisms as needed. The same goes for an entity's UI components. This way, a large feature's UI structure remains clean and understandable, mirroring the Atomic layers but scoped to that feature.

  2. Shared Atomic Components for Cross-Cutting UI: The global shared/ui is the place for truly universal atoms and molecules (and maybe some organisms) that are used by many slices. Think of this as your design system or UI toolkit that all features can draw from. For example, basic form controls, buttons, icons, and maybe common layouts reside here. By putting them in shared, you ensure there is a single source of truth for these elements (consistent styles, no duplication).

  3. Feature-Specific Components remain in Feature: If a UI component is only relevant to one feature, keep it in that feature's ui folder instead of cluttering shared. For instance, a ProfileCard organism that is only used on the user profile page belongs in features/user-profile/ui/organisms/. It can still be composed of shared atoms/molecules like Button or Input, but the organism itself lives with its feature. This maintains high cohesion – all code for "user profile" lives together – and avoids accidental coupling – other features won't randomly use ProfileCard unless they truly need to (in which case maybe it should be moved to a more shared location).

  4. Entities Provide Domain-Specific Atoms/Molecules: Entity slices can also contain UI components that represent core domain concepts. For example, the user entity might include an atom like UserAvatar or a molecule like UserNameLabel. These could be used by multiple features whenever they need to show a bit of user info. By placing them in entities/user/ui, we signal that these are generic representations of a user for use anywhere in the app, but still part of the user domain (they might have knowledge of a User type, for instance). This avoids duplicating things like "display a user's name with a certain styling" in every feature that needs it – you write it once in the entity. Another feature could import UserAvatar from the user entity instead of making its own.

Let's visualize how a combined Atomic + FSD structure might look for a specific feature and entity:

src/
├── features/
│   └── user-profile/
│       ├── ui/
│       │   ├── atoms/
│       │   │   └── EditButton.tsx              # small button specific to profile
│       │   ├── molecules/
│       │   │   └── ProfileContactInfo.tsx      # displays email & phone together
│       │   └── organisms/
│       │       └── UserProfileCard.tsx         # larger component composing many pieces
│       ├── model/
│       │   └── useUserProfile.ts               # feature logic (e.g., fetch user, handle edit state)
│       ├── api/
│       │   └── updateProfile.ts                # API call to update user profile
│       └── index.ts                            # Public API exports (e.g., UserProfileCard component)
├── entities/
│   └── user/
│       ├── ui/
│       │   ├── UserAvatar.tsx                  # atom: user profile picture display
│       │   └── UserNameLabel.tsx               # molecule: maybe styled username text
│       ├── model/
│       │   ├── types.ts                        # e.g., User interface/type
│       │   └── userStore.ts                    # (optional) state management for user data
│       ├── api/
│       │   └── fetchUser.ts                    # API call to get user info
│       └── index.ts                            # Public API exports (UserAvatar, types, etc.)
└── shared/
    ├── ui/
    │   ├── atoms/
    │   │   ├── Button.tsx
    │   │   ├── Input.tsx
    │   │   └── Spinner.tsx
    │   ├── molecules/
    │   │   └── FormField.tsx
    │   └── organisms/
    │       └── Modal.tsx
    ├── lib/
    │   └── formatDate.ts
    └── config/
        └── routes.ts

In this example, the User Profile feature (features/user-profile) uses a mix of its own UI components and those from the User entity and shared library. The UserProfileCard organism might internally use the UserAvatar (from entities/user) to show the picture, a UserNameLabel (also from entities/user) to show the name, and maybe a Button (from shared/atoms) for an edit action. All those pieces come together only within the UserProfileCard, which is exported as the feature's main UI component.

From the outside, other parts of the app don't need to know about the internals. The Profile page can simply import { UserProfileCard } from "features/user-profile" (via that feature's index.ts). The UserProfileCard, in turn, imports what it needs from the user entity and shared UI. This layering ensures a clean dependency graph: Page → Feature → Entity/Shared, without circularity.

How does this improve maintainability and reusability? Let's enumerate the benefits:

Clear Domain Boundaries: All code related to the user profile feature sits in one folder. If a developer needs to modify the profile functionality, they know where to look. They won't accidentally break unrelated features because they won't be touching other slices. Conversely, if something breaks in the profile feature, you know it likely resides in that slice. This isolation is a hallmark of feature-sliced design.

Shared UI Consistency: By using shared atoms (like Button, Input) throughout, the look and feel remain consistent. If the design team updates the primary button style, you update shared/ui/atoms/Button.tsx once, and every feature's buttons automatically reflect the change. The atomic approach ensures there's one canonical implementation of each fundamental UI element, reducing inconsistencies.

Controlled Reuse via Entities: Entities act as mediators for reuse of domain logic and UI. For example, both the profile feature and perhaps a "user management" feature (for admins) might need to display user avatars. By having a UserAvatar in the user entity, we avoid duplicating that component in two places. Yet, it's not globally shared in a way that invites misuse – it's specifically tied to user data (it likely expects a User object as prop). This means improvements to UserAvatar benefit all usages, and any domain-specific logic (like maybe showing a default avatar if none provided) is implemented once. It's a balance between DRY and separation – we stay "DRY" for things that truly represent the same concept across the app (like a user avatar).

Feature Independence & Composability: If one feature needs to use functionality from another, we extract that functionality to an appropriate place. For instance, say the task list feature wants to show the name of the user who created each task. It should not import a UI component from the profile feature; instead, it can use UserNameLabel from the user entity (since user names are a general concept). If something wasn't in an entity or shared yet, that's a signal to refactor and move it there. Each feature exposes only what is necessary (often one or two main components and perhaps some hooks). This keeps the coupling low. In fact, you could potentially remove an entire feature folder and the rest of the app would still compile/run (minus the part that tried to import that feature's API). That's a strong sign of modularity.

Scalability for Teams: Teams can work on separate features in parallel without stepping on each other's toes. If Feature A and Feature B don't directly depend on each other, two developers can refactor or develop them simultaneously. Merge conflicts and inter-team coordination needs are reduced. If the codebase was all tangled, adding a new team member would be like dropping them into a messy kitchen; but in a sliced architecture, each team or person can own a slice (or set of slices) cleanly.

By blending Atomic Design with Feature Slices, we essentially build a design system inside each context. The Atomic principles guide how we build components and keep them reusable and consistent. The Feature-Sliced structure ensures those components are organized by the feature or domain they serve. We avoid the extremes of "totally global, context-less components" on one hand and "copy-pasted similar components in every feature" on the other.

Next, let's go through a concrete scenario to see these ideas in action.

Case Study: Building a Scalable Dashboard

To cement the concepts, we’ll walk step-by-step through designing a simplified dashboard application using Atomic Design + Feature Slices. Imagine we’re building a dashboard page that shows an overview of a user’s account, including their profile info and a list of tasks they need to complete. We’ll identify the entities and features involved, create the folder structure, and write a bit of sample code to illustrate the interactions.

Step 1: Identify Core Entities

For our dashboard, two obvious entities are User and Task. The User entity will represent user information (name, avatar, etc.), and the Task entity will represent an individual to-do item. These are business concepts that will likely be used in multiple places (not just on the dashboard), so they merit their own entities/ modules.

We create src/entities/user/ and src/entities/task/ folders. In each, we define the data model and any domain-specific utilities:

  • entities/user/model/types.ts might define a User TypeScript interface.
  • entities/user/api/fetchUser.ts might have a function to load user data from an API.
  • entities/user/ui/UserAvatar.tsx could be a React component to display a user's profile picture (an atom), and UserCard.tsx might be a small organism to show basic user info (name + avatar).

Similarly for Task:

  • entities/task/model/types.ts for a Task interface (e.g. { id, title, completed, dueDate, ... }).
  • entities/task/api/fetchTasks.ts to get a list of tasks.
  • We might not need a entities/task/ui component if tasks are mainly displayed via a feature's UI, but if we foresee reuse (say, many features showing task info), we could include something like TaskItem.tsx as a molecule for a single task row.

Step 2: Define Features for the Dashboard

Next, think about distinct features on the dashboard page:

The User Profile feature: a section of the dashboard that displays the user's information (and perhaps allows editing it or navigating to profile settings). Let's call this feature user-profile. It will use the User entity under the hood.

The Task List feature: a section showing the user's tasks or to-do list. We'll call this task-list. It will use the Task entity's data and possibly the User entity (if tasks have an assignee or owner field with user info, though for simplicity we might assume these are the user's own tasks).

We create src/features/user-profile/ and src/features/task-list/ folders. Within each:

  • The user-profile feature will have ui/UserProfileWidget.tsx (an organism that displays the user's name, avatar, and maybe a link to edit profile), and possibly some smaller components like ui/atoms/EditProfileButton.tsx or ui/molecules/ProfileStats.tsx if needed. Its model could contain a useUserProfile hook that uses fetchUser from the User entity to get data, and manages any local editing state. Its index.ts will export the main component (UserProfileWidget) and any hook that pages might use.

  • The task-list feature will have ui/TaskListWidget.tsx (organism that shows a list of tasks, perhaps with each task item and a form to add new tasks). It might have smaller components like ui/TaskItem.tsx (molecule or organism representing one task row with a checkbox), and ui/AddTaskForm.tsx. Its model might include a hook useTaskList that fetches tasks (using fetchTasks from Task entity) and handles adding/completing tasks. The feature's index.ts exports the TaskListWidget component and maybe the hook.

Each feature encapsulates a piece of UI and logic for the dashboard, but they could also be used elsewhere. For example, you might include TaskListWidget on a dedicated Tasks page as well as on the dashboard.

Step 3: Layout with Pages and Templates

We'll have a DashboardPage in src/pages/DashboardPage.tsx. This page component will assemble our features into the final UI. Perhaps we also have a ProfilePage for a full profile management screen, but let's focus on the dashboard for now.

If the dashboard shares a common layout (header, sidebar, etc.) with other pages, we could have a shared/ui/templates/DashboardLayout.tsx to enforce consistent structure. For brevity, let's assume DashboardPage handles layout inline or uses a generic layout.

Step 4: Composing it all together

Now we wire up the pieces. The Dashboard page will import the necessary feature components. Thanks to our architecture, these imports are straightforward:

// pages/DashboardPage.tsx
import { UserProfileWidget } from '@/features/user-profile'
import { TaskListWidget } from '@/features/task-list'
import { FC } from 'react'

const DashboardPage: FC = () => {
  return (
    <main className="dashboard-page">
      <h1>Dashboard</h1>
      <section className="user-profile-section">
        <UserProfileWidget />
      </section>
      <section className="tasks-section">
        <TaskListWidget />
      </section>
    </main>
  )
}

export default DashboardPage

Here we assume each feature's index.ts re-exports the primary component (UserProfileWidget, TaskListWidget), so we can import directly from the feature path. The page simply places those widgets on the screen. Notice that DashboardPage doesn't need to know how UserProfileWidget or TaskListWidget work; it doesn't manage their state or data – that's the feature's job. This keeps the page component very lean, basically just a layout + composition.

Let's peek into how a feature might implement its logic using the entity and shared pieces:

// features/user-profile/ui/UserProfileWidget.tsx
import { FC } from 'react'
import { useUserProfile } from '../model/useUserProfile'
import { UserAvatar } from '@/entities/user'
import { Button } from '@/shared/ui/atoms/Button'

export const UserProfileWidget: FC = () => {
  const { user, loading, error, refresh } = useUserProfile()

  if (loading) return <div>Loading profile...</div>
  if (error) return <div>Error loading profile.</div>
  if (!user) return null

  return (
    <div className="user-profile-widget">
      <UserAvatar user={user} size={80} />
      <h2>{user.name}</h2>
      <p>Role: {user.role}</p>
      <Button onClick={refresh}>Refresh</Button>
    </div>
  )
}

A few things to note in this UserProfileWidget component:

  • It uses a custom hook useUserProfile from its own model. That hook might internally call fetchUser from the User entity and perhaps store the result in local state. It provides user, loading, error, and a refresh function to re-fetch – encapsulating the data logic for the profile. This follows the principle that business logic lies in the model layer, not in the UI components directly. The UI component just consumes the data and renders.

  • It imports UserAvatar from the user entity's UI. This avatar component likely expects a user object and a size, and it takes care of rendering the profile image (maybe falling back to a default image if none). By using UserAvatar, we ensure the profile image is shown consistently here and anywhere else in the app that displays a user's avatar. If tomorrow we change UserAvatar to display a fancy border around admins, that change instantly applies here too.

  • It also imports a generic Button from shared atoms. This could be a styled button from our design system. Using it means the "Refresh" button will look the same as other buttons in the app, and any style change is centralized.

The TaskListWidget feature would look somewhat similar: its UI component might map over a list of tasks, using maybe a TaskItem sub-component for each. It would use a useTaskList hook to get tasks (which uses fetchTasks from Task entity), and perhaps use a shared Button or Input for adding new tasks.

Step 5: Running the App

When the user navigates to the Dashboard page, DashboardPage renders, which in turn renders UserProfileWidget and TaskListWidget. Those will trigger their respective hooks (useUserProfile, useTaskList) to fetch data via the entities. Once data is in, the features' UI components render atoms and molecules from both shared and entity layers to display the information. We've effectively separated concerns:

  • The User entity knows how to get user data and how to represent basic user info (avatar, etc.).
  • The UserProfile feature knows how to combine user data with UI for the profile section.
  • The Task entity knows how to fetch tasks and defines what a Task is.
  • The TaskList feature handles listing tasks and task-related UI.
  • The Dashboard page just ties features together in the right spots.
  • The shared UI provides consistent basic components to all.

If we needed to add a new feature to the dashboard, say a NotificationsWidget, we could create features/notifications/ without touching much else. The page would import and include <NotificationsWidget /> in a new section. The notifications feature could use a Notification entity if one exists or just be self-contained. This modular growth is a hallmark of the combined approach – you add new pieces like attaching new Lego blocks, without rebuilding the whole structure.

Summary of the Case Study Structure

src/
├── entities/
│   ├── user/
│   │   ├── model/       # User types, maybe a user context/store
│   │   ├── api/         # fetchUser, etc.
   │   └── ui/          # UserAvatar, UserNameLabel
│   └── task/
│       ├── model/       # Task type
│       ├── api/         # fetchTasks, addTask, etc.
       └── ui/          # perhaps TaskItem if widely reused
├── features/
│   ├── user-profile/
│   │   ├── model/       # useUserProfile hook
│   │   ├── ui/          # UserProfileWidget, maybe subcomponents
│   │   └── index.ts     # exports UserProfileWidget
│   └── task-list/
│       ├── model/       # useTaskList hook
│       ├── ui/          # TaskListWidget, TaskItem, AddTaskForm
│       └── index.ts     # exports TaskListWidget
├── pages/
│   └── DashboardPage.tsx    # uses UserProfileWidget, TaskListWidget
└── shared/
    ├── ui/              # atoms: Button, Input; molecules: maybe a generic SearchBar or Modal
    └── lib/             # e.g., date utils

The end result is a scalable dashboard architecture. Each new requirement finds an intuitive place in this structure:

  • Need to display user's team members on the dashboard? That might introduce a Team entity and a TeamList feature.
  • Need a settings panel? Add a settings feature.
  • The design system evolves? Update shared/ui components.
  • A feature becomes very large? You can even subdivide it further internally (maybe split some sub-feature or use more atomic folders).

Nothing ever feels completely out of place, because the division of responsibilities is clear.

Best Practices

Designing a scalable frontend architecture is not just about the initial structure – it's about the guidelines you follow throughout development. Here are some best practices to keep your Atomic + Feature-Sliced Design implementation effective and maintainable:

Adhere to the Dependency Rule: Enforce the rule that slices do not depend on other slices at the same layer, and higher layers depend downward. For example, do not import something from another feature's internal file; instead, promote shared logic to an entity or the shared layer. This keeps coupling low. Use ESLint rules or module boundary checks if possible to automate this enforcement.

Public APIs and Barrel Files: Each feature and entity should provide an explicit public interface. Typically, this is an index.ts file exporting only the components, hooks, or functions that other modules should use. Consumers of the module should import from the slice root (e.g. import { UserProfileWidget } from '@/features/user-profile'), not deep inside ui/ or model/. This encapsulation allows you to reorganize files internally without breaking other parts of the app, and it clearly communicates what's intended to be used externally.

SOLID Component Design: Apply SOLID principles at the component and module level. Single Responsibility Principle – each component or module should have one reason to change. A component like UserProfileWidget should ideally only handle rendering user info; if it's also managing complex state or making API calls, consider moving that out to a hook or context (the model layer). Open/Closed Principle – build components that can be extended (via props or composition) rather than modified; for instance, your atomic components can accept props for different styles or content, instead of having separate components for every variation. Dependency Inversion – high-level features shouldn't be tightly coupled to low-level implementation details; e.g., a feature can rely on an interface (TypeScript type or an abstracted hook) rather than a concrete data-fetching method, so you could swap out the data source (perhaps for mock data in tests or a different API). These principles improve maintainability.

DRY vs WET – Be Pragmatically DRY: Don't repeat yourself… unless it makes sense. Strive to reuse components and logic by placing them in shared or entities once you see a real need. But avoid the temptation to prematurely abstract something just because "it might be reused". It's often better to duplicate a little code for two features than to create a convoluted shared abstraction that doesn't quite fit both needs. Over-abstraction can be just as harmful as duplication. A good heuristic: if you find the same bug fix has to be applied in two places or the same change made in two places, that's a hint to unify them. Otherwise, a bit of copy-paste is okay if it keeps features independent. Remember, "shared" code should be truly shared; if it's only used by one module, keep it local.

Keep Atomic Components Pure: Atoms and molecules (especially those in shared/ui) should be presentational and free of business logic or heavy state. They might have internal UI state (like a controlled input's state, if not managed by parent), but they shouldn't be making API calls or relying on global context. This makes them easy to reuse and test in isolation. Business logic (data fetching, complex state management) should live in the model layer of features or entities (e.g. hooks, controller functions). This separation follows the classic Smart vs Dumb component pattern – where dumb (presentational) components live in the UI segments and smart (container) logic lives in model segments or higher-level organisms.

Use Descriptive Naming: Name your slices and components after the domain or feature they represent, not after technical details. For example, user-profile feature is better than calling it ProfileWidget (which is vague) or ProfileFeatureA. Good names make the codebase "scream" its intent (a concept from "screaming architecture") – when you open the project, you immediately see what the app is about by looking at folder names (users, tasks, notifications, etc. instead of generic components, misc, stuff). This helps new developers (and your future self) quickly navigate the code.

Encapsulate Styles and Assets: Keep styling and assets close to the component or feature they belong to. For instance, if UserAvatar uses a CSS module or a specific image, put it in the entities/user/ui folder alongside UserAvatar.tsx. This follows the same colocation principle – everything related to that component stays together. You can still have a shared global style (for resetting or theming), but feature-specific styles should reside in the feature. This makes it easier to delete or move features without leaving orphaned styles.

Gradual Layering for New Projects: If you're starting a new project, you don't have to implement every layer of FSD on day one. You might begin with just app, pages, features, and shared for simplicity. As the project grows, introduce entities when you notice repeated domain concepts across features, and introduce more segmentation when features become complex. The Atomic Design ideas can be applied even in a lightweight way: e.g., maybe you start with just components/ for each feature and later split into atoms/molecules folders as needed. The key is to refactor toward this architecture as complexity increases. It's flexible – you can start simple and add structure incrementally, as long as you keep the overarching principles in mind.

Ensure Consistent Code Reviews: Make architecture a collective responsibility. During code reviews, watch for violations of the intended architecture (like someone importing from another feature's internals or dumping something in shared that shouldn't be). Agree on the module boundaries and help each other stick to them. This prevents architectural erosion over time. If a use-case truly doesn't fit the established structure, discuss how to adjust – maybe the architecture needs to evolve, or maybe the code should be reorganized. It's better to adjust intentionally than to let inconsistency creep in unaddressed.

Barrels and Index Files for Dependencies: We mentioned index files for public API, but also consider using barrel files within segments if it helps. For example, inside features/task-list/ui/, if there are many UI components, you might have an index.ts that exports all of them for convenience. This way the feature's main component can import sub-components via a single path. Be cautious though – avoid making a barrel that exports things which shouldn't be used outside. Barrels are for convenience, not to break encapsulation.

Testing in Isolation: The modular structure allows for more focused testing. You can write unit tests for atomic components (e.g., Button, Input) without any app context. You can test feature logic (the hooks in model) by mocking the entity API calls. And you can do integration tests for pages, mounting a page and verifying that the composed features work together. Encourage writing tests at the slice level – e.g., tests for user-profile feature should cover how it behaves with various user data scenarios (you can mock the user entity API). This ensures each piece works independently, so the whole app integration has fewer surprises.

By following these practices, you ensure that your architecture doesn't just look good on paper, but continues to serve you well as the codebase grows. Think of your project structure as a living design – periodically revisit it and ask, "Does this still scale? Are we seeing pain anywhere?" If a particular shared component is becoming a chokepoint or a feature folder is exploding in size, that's a sign to refactor (maybe split a large feature into sub-features or move some parts to entities/shared). A great architecture is one that can adapt without breaking.

Common Mistakes

Even with a solid methodology, it's easy to fall into some traps when implementing Atomic Design and Feature Slices together. Be on the lookout for these common mistakes:

Over-Engineering from the Start: It's possible to go overboard with structure in a small or new project. If you create every possible layer, dozens of folders, and abstract components that aren't needed, you can stifle development agility. Avoid creating empty boilerplate folders "just in case." Only add complexity (new layers, abstractions) when the project's growth demands it. For example, don't split every component into an atom/molecule if you only have a handful of components – that may be overkill. Evolve the architecture as a response to pain points, not in anticipation of every theoretical issue.

Premature Abstraction & Sharing: This ties into the DRY discussion – making something shared or abstract too early. A telltale sign is a shared component with a lot of configuration flags or props to handle different use cases, when in reality those use cases could have been separate simpler components. If you find yourself writing if (prop) do A else do B inside a shared component to accommodate two different screens' needs, you might have merged two things that should have stayed separate. Instead, keep it simple and duplicate or wrap components until a clear pattern emerges that justifies unification.

"Shared" Misuse and Coupling: Overusing the shared/ layer as a dumping ground for anything can reintroduce the chaos we tried to eliminate. For instance, placing a function like calculateInvoiceTotal in shared/lib when it's really only used in the billing feature is mis-categorization. That function belongs in a specific slice (maybe an entities/invoice or within a billing feature's model). The shared layer should ideally have no knowledge of business domains. Everything in shared should be generic. If you catch domain terms in shared/ (like "invoice", "userProfile"), reconsider their placement. A bloated shared folder can become a maintenance headache and blur boundaries.

Breaking Feature Encapsulation: This happens when developers bypass the intended module boundaries. For example, imagine we have features/user-profile and someone writes import { UserProfileCard } from '../user-profile/ui/UserProfileCard' inside another feature or page. This deep import violates encapsulation – they should import from features/user-profile (index) or, even better, consider if that usage means the component should be moved to a more shared location. Such shortcuts usually indicate either lack of familiarity with the architecture or an architectural gap (maybe that component should be shared). The danger is that if the user-profile internal structure changes, the other import breaks. To avoid this, always go through public APIs of slices. If you find you must reach in, treat it as tech debt to resolve.

Inconsistent Atomic Categorization: Atomic Design terms can be subjective at times. One team member might consider a dropdown menu a molecule, another might call it an organism. That's fine – the exact definitions are less important than consistency. The mistake is when the project has no clarity on what belongs where, leading to confusion. Resolve this by defining some guidelines: e.g., "Atoms are single HTML elements or icon components; molecules are combinations of 2-3 atoms; organisms are complex composites or sections of UI." And don't agonize over edge cases – the categories are just to help organize. If something doesn't neatly fit, use your best judgment or even just put it in ui/ without further classification if it's not reusable.

Too Many Micro-Components: Sometimes enthusiasm for Atomic Design leads to over-splitting components. If you break every piece of UI into the tiniest atoms, you may end up with a proliferation of components that are hard to navigate and reason about. For example, making a separate atom for every icon or every piece of text might be overkill. It's okay to have some larger components if they aren't reused elsewhere. Aim for a balance in granularity. A good practice is to create a new component (atom/molecule) only when you either plan to reuse it or it significantly simplifies the parent component. Otherwise, a bit of inline JSX in an organism is fine.

Neglecting Performance in Abstraction: An architecture can be clean but sometimes introduce performance overhead if not careful. For instance, wrapping everything in context providers or overusing Redux for local feature state could slow things down or complicate render logic. Keep an eye on React rendering – if your feature widget is rerendering too often, consider memoization or splitting it into smaller components. Architecture should not come at the expense of a snappy UI. With Atomic components, ensure that e.g. a pure atom like Button is a memoized functional component or a simple one to render. Also be mindful of bundle size: if each feature is very decoupled, watch out for code duplication across feature bundles (like if each feature independently includes a large library – maybe that belongs in shared/lib to avoid duplication).

Ignoring Team Understanding: A fancy architecture is useless if the team doesn't buy into it or understand it. One mistake is having one architect set it up, and others are confused about where to put code, so they start shoving things in random places (like a new API call just put under shared/ because they didn't realize an entity would be appropriate). Solve this by proper onboarding: create a README for your project structure, give a tour of the architecture to all team members, and maybe pair on the first few features to reinforce the patterns. Periodically, do architecture retrospectives to see if everyone is following or if certain parts are confusing and need adjustment.

Rigidity – Not Evolving the Architecture: On the flip side of not following it, sometimes teams stick to an initial architecture even when the app's needs have changed. For example, maybe you started without an entities/ layer and now you have lots of duplicate logic across features – it's time to introduce entities. Or you realize a "widget" concept is needed for chunking bigger features. Avoid dogma; the architecture should serve the app, not the other way around. It's okay to refactor the structure as you learn more about the domain. Perhaps you find that some features really should be merged, or an existing feature should be split into two. Make those changes with proper planning rather than limping along with an ill-fitting structure.

To sum up, the biggest mistakes revolve around misplacing code and misjudging the level of abstraction. By staying vigilant and keeping the system's design principles in mind, you can steer clear of these pitfalls. And if you stumble into one, don't worry – every large project incurs some technical debt or architecture challenges; the key is recognizing them and refactoring consciously.

Conclusion

Designing a frontend architecture that scales is a journey from thinking in terms of individual components to thinking in terms of cohesive systems. By marrying Atomic Design with Feature-Sliced Design, we get a robust approach that addresses both sides of the scale challenge: we create a system of reusable UI parts and a modular structure for complex features. This combination yields an application that is clearer to navigate, easier to extend, and safer to refactor than the ad-hoc, flat architectures of the past.

We've seen how Atomic Design gives us a vocabulary and hierarchy for UI elements, encouraging consistency and reuse across the interface. We've also seen how Feature-Sliced Design divides the app along logical lines, so that each feature and entity is a world of its own, loosely coupled to others. By applying atomic principles within the boundaries of features and entities, we ensure that our code is DRY without being too tightly coupled, and modular without being fragmented.

The benefits of this approach are significant: new team members can quickly find where something lives (the file structure narrates the app's story), adding a new feature doesn't require touching unrelated parts of the code, and redesigning a common component or flow can be done in one place. It increases clarity, scalability, testability, and even team velocity, since multiple people can comfortably collaborate on different slices of the app concurrently.

As you implement these ideas, remember the essence: design systems, not pages. This mantra, originally from Brad Frost, encapsulates the shift in mindset. We are not just coding individual pages or components in isolation; we are building a living system of parts that interact. When you focus on the system – the architecture, the consistency, the contracts between modules – you create a codebase that survives the twists and turns of product evolution. Features will come and go, UI trends will change, but a well-structured system can adapt with far less effort and without collapsing under its own weight.

In practice, no architecture is a silver bullet. There will be trade-offs and occasional rule-bending. But the combination of Atomic Design and Feature Slices provides a strong foundation and guiding compass. It encourages us to be deliberate about every piece of code: Why does this exist? Where should it live? Who should use it? Answering these questions leads to purposeful code organization.

By following the principles and avoiding the pitfalls discussed, you'll be well on your way to building frontends that are as scalable and maintainable as the backends powering them. You'll spend more time delivering value and less time untangling spaghetti code or reinventing buttons for the 100th time.

In the end, great architecture often feels invisible – things are just where you expect, and making changes "just works." Achieving that takes forethought and occasional refactoring, but it pays off exponentially as your application grows. So as you go forward, embrace the mindset of a system designer. Keep your components small and your abstractions meaningful. Keep your features isolated but your UI consistent. And never lose sight of the fact that our goal is to build software systems that can grow gracefully – from a single component to a complex web of features – without breaking apart.

Happy architecting, and enjoy building systems that make both developers and users smile!


Further Learning Resources & References

To deepen your knowledge of scalable frontend architecture, Atomic Design, and Feature-Sliced Design, here are some high-quality resources:

  • Atomic Design by Brad Frost – The original book introducing the Atomic Design methodology. A must-read for understanding how to build design systems from atoms to pages.

  • Feature-Sliced Design Official Documentation – The official FSD documentation with detailed explanations of layers, slices, segments, and best practices for organizing frontend projects.

  • Storybook – An essential tool for developing and documenting atomic components in isolation. Perfect for building and showcasing your design system.

  • Bulletproof React – A scalable React architecture example that incorporates feature-based organization and best practices for production-ready apps.

  • React TypeScript Cheatsheet – Comprehensive guide for typing React components, hooks, and patterns in TypeScript – essential for implementing typed atomic components.

  • Design Systems Handbook by DesignBetter – A free guide on building and maintaining design systems, covering both design and development perspectives.

  • Component Driven Development – Resources and methodology for building UIs from the component level up, aligning well with Atomic Design principles.

  • SOLID Principles in React – Practical guide on applying SOLID principles to React components and architecture.

  • Modular Monolith Frontend – Article exploring modular architecture patterns that complement Feature-Sliced Design concepts.

  • ESLint Plugin Boundaries – ESLint plugin to enforce module boundaries and dependency rules in your codebase – useful for maintaining FSD architecture.

~Seb

Suggested posts

Related

Client-Side React Is Dead: Why Server Components Are the Future You Can't Ignore

An in-depth guide for advanced frontend developers on applying DRY, KISS, and SOLID principles in JavaScript, TypeScript, React, and Vue.

Learn more →
Related

Principles of Clean Frontend Code: DRY, KISS, and SOLID

An in-depth guide for advanced frontend developers on applying DRY, KISS, and SOLID principles in JavaScript, TypeScript, React, and Vue.

Learn more →