Stop Treating `components` and `utils` Like Project Dumpsters
How Exactly Should You Design a Directory Structure? Stop Using components and utils as Trash Cans
Many front-end projects become messy, and it all starts with two folders:
components/
utils/
At first, they seem perfectly reasonable.
Public components go in components, utility functions go in utils. Simple, intuitive, and everyone understands.
But six months into a project, when you open them again, they might look like this:
components/
├── BaseButton.vue
├── UserCard.vue
├── OrderStatus.vue
├── UploadDialog.vue
├── SearchForm.vue
├── PermissionWrapper.vue
├── BusinessTable.vue
└── SomeOldModal.vue
utils/
├── format.ts
├── request.ts
├── auth.ts
├── tree.ts
├── permission.ts
├── download.ts
├── route.ts
├── date.ts
└── xxx-helper.ts
Every file seems to have a reason for existing, but you can no longer clearly say:
- Which are purely public capabilities?
- Which are actually part of a business module?
- Which are used by only one page?
- Which should sink down to the foundational layer?
- Which will be reused by multiple applications in the future?
- Which shouldn't be here at all?
At this point, components and utils are no longer directories; they are trash cans.
This article wants to discuss a very practical question: How exactly should the directory structure of a front-end project be designed?
1. Directory Structure Is Not About "Looking Neat"
When many people design a directory structure, their first instinct is to apply a template:
src/
├── assets/
├── components/
├── hooks/
├── router/
├── store/
├── utils/
├── views/
└── api/
This structure isn't wrong. For small projects, back-office management systems, or one-off campaign pages, it works perfectly fine.
The problem is that many people think having this directory structure means the project has an architecture.
It doesn't.
The value of a directory structure is not to make files look pretty, but to answer three questions:
- Responsibility Boundary: Which layer does this file belong to?
- Reason for Change: For what reason will it be modified?
- Dependency Direction: What is it allowed to depend on? Who is allowed to depend on it?
If a directory cannot answer these three questions, it will eventually become a "put anything here" dump.
Take utils, for example.
It sounds like "utility functions," but what defines a utility?
Date formatting is a utility, tree structure conversion is a utility, request encapsulation is a utility, permission checking is also a utility, and some people even put route navigation helpers in there.
In the end, utils becomes a storage box with no boundaries.
Once a directory loses its boundaries, the code will gradually lose its boundaries too.
2. Grouping Directories by File Type Only Works in the Early Stages
The most common way to design a directory structure is by file type:
components/
hooks/
utils/
api/
store/
views/
The advantage of this approach is that it's quick to pick up.
A newcomer can see at a glance that components go in components, requests in api, state in store, and pages in views.
But its problem is also obvious: it can only tell you "what this file is," not "who this file belongs to."
For example.
You have an order module, which includes:
- An order list page
- An order detail page
- An order status component
- Order amount formatting logic
- Order APIs
- Order-related types
- An order search form
If you strictly group by file type, it will be scattered like this:
views/order/List.vue
views/order/Detail.vue
components/OrderStatus.vue
components/OrderSearchForm.vue
utils/order.ts
api/order.ts
types/order.ts
store/order.ts
It looks like every file is in the "correct type" directory.
But the "order" business module has been torn apart.
If you want to change order logic, you need to search back and forth across 6 directories. A newcomer trying to understand the order module also has to piece the puzzle together themselves.
This is the limitation of grouping directories by type:
It brings files of the same type closer together, but scatters the related code of a single business domain.
It doesn't matter when the project is small. As the project grows, the cost of understanding it will get higher and higher.
3. What You Really Should Care About Is "Why This File Will Change"
I now prefer to use one question to decide where a file should go:
For what reason will this file be modified?
If two files are always changed together due to the same business change, they should be close to each other.
If a file has nothing to do with business and is purely a technical capability, it should sink down to the foundational layer.
If a file only serves a single page, don't rush to put it in a public directory.
For example, an OrderStatus.vue.
Should it go in components, or under the order module?
Don't look at whether it's a component; look at its reason for change.
If it only serves the order business, and its status text, colors, and transition rules are all bound to the order domain, then it should not be in the global components. It should be close to the order module.
features/order/
├── pages/
├── components/
│ └── OrderStatus.vue
├── services/
├── types.ts
└── utils.ts
If it is a pure display Tag component, unrelated to orders, and usable by any business, then it should enter the shared UI layer.
shared/ui/
└── StatusTag.vue
The judging criterion is not "is it a component," but "is it business-specific."
Let's look at another one, formatAmount.
If it just formats a number into a currency amount:
formatAmount(1234.5) // ¥1,234.50
It can be placed in the shared utility layer.
But if it contains order business rules, like different order types displaying different precisions, different currencies, or different discount calculation methods, then it is no longer a general utility but order domain logic.
Many projects become chaotic precisely because business logic is disguised as utils.
4. components Is Not the Home for All Components
The biggest problem with the name components is that it's too broad.
As long as it's a .vue file, it seems like it can be put in there.
But components can be divided into at least three categories:
1. Base UI Components
Examples include Button, Input, Modal, Table, StatusTag.
They don't care about specific business logic, only about display and interaction.
These types of components are suitable for the shared UI layer:
packages/ui/
└── src/
├── button/
├── modal/
└── status-tag/
Or in a single application:
src/shared/ui/
2. Business Components
Examples include OrderStatus, UserRoleSelector, InvoicePreview.
They are strongly bound to business concepts and are meaningless outside of that business.
These components should be placed inside the corresponding business module:
src/features/order/components/OrderStatus.vue
src/features/user/components/UserRoleSelector.vue
3. Page-Local Components
Examples include FilterPanel, EditDialog, DetailHeader within a specific page.
They are only used by one page.
These components don't need to be hastily made public; just place them near the page:
src/features/order/pages/list/
├── index.vue
├── FilterPanel.vue
└── EditDialog.vue
Many projects become chaotic because things are moved into public directories too early.
A public directory is not a hall of fame; more is not better.
"Public" implies a commitment:
- You must maintain its stability.
- You must consider reuse scenarios.
- You must avoid business logic contamination.
- You must bear the cost of future compatibility.
If a component is only used in one place, let it stay there.
Wait until it is genuinely reused in a second or third scenario before considering extracting it.
5. utils Is Also Not the Home for All Functions
utils is even easier to lose control of.
Because functions are not as conspicuous as components. A function might be only 10 lines today, gain a parameter tomorrow, and have a business rule stuffed into it the day after, quickly transforming from a utility function into a business rule.
I suggest dividing functions into at least four categories.
1. Pure General Utilities
These functions do not depend on business, frameworks, or the runtime environment.
Examples:
- Date formatting
- Number formatting
- Null value checks
- Tree structure conversion
- String processing
They can be placed in the shared utility layer.
shared/utils/
Or in a monorepo:
packages/shared/src/utils/
2. Business Rule Functions
These functions look like utilities but are actually bound to business rules.
Examples:
- Determining if an order can be cancelled
- Determining if a user can edit a resource
- Calculating display text based on business status
- Calculating amount display based on order type
They should be placed inside the corresponding business module.
features/order/domain.ts
features/order/rules.ts
features/order/utils.ts
If you put them in shared/utils, the shared layer will be contaminated by business logic.
3. Platform Capability Wrappers
Examples:
- localStorage access
- File download
- Clipboard access
- Bridge calls
- Route navigation helpers
These functions depend on the browser, client, or framework environment and are not pure utilities.
They should have more explicit directory names, such as:
shared/platform/
shared/browser/
shared/runtime/
Don't use utils to obscure their dependencies.
4. Service Layer Helpers
Examples include token injection, request error handling, and response transformation.
These should not be placed in utils but should belong to the services layer.
services/
├── core/
├── interceptors/
└── api/
The more specific the directory name, the clearer the boundary.
utils is not unusable, but it should be very small and very pure.
If a project's utils keeps growing, it usually means you haven't seriously divided responsibilities.
6. A More Maintainable Directory Approach
For a typical mid-to-back-office single application, I would lean towards a structure like this:
src/
├── app/ # Application assembly: routing, plugins, global initialization
├── shared/ # Shared capabilities unrelated to specific business
│ ├── ui/ # Base UI components
│ ├── utils/ # Pure utility functions
│ ├── platform/ # Browser/runtime capabilities
│ └── types/ # General types
├── services/ # Request layer, API instances, interceptors
├── features/ # Business modules
│ ├── order/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── services/
│ │ ├── types.ts
│ │ └── rules.ts
│ └── user/
│ ├── pages/
│ ├── components/
│ ├── services/
│ ├── types.ts
│ └── rules.ts
└── main.ts
This structure may not be suitable for all projects, but it answers a few more questions than a simple components/utils/views/api setup:
appis responsible for application startup and assembly.sharedholds business-unrelated shared capabilities.servicesholds requests and service communication.featuresis organized by business module.- Modules are allowed to have their own components, types, rules, and APIs internally.
Its core is not the directory names, but the boundaries:
Business-related things stay close to the business, business-unrelated things sink to the shared layer, and application startup logic is consolidated in the
applayer.
7. For a Monorepo, the Directory Structure Needs to Be Viewed One Level Up
In a single application, we discuss how to organize src.
But if your team has multiple applications, such as:
- A PC back-office management system
- An H5 campaign site
- An in-app embedded page
- An operations tool
- A data dashboard
The problem changes.
You're not just organizing the directories within one application, but also organizing the sharing relationships between multiple applications.
This is also the reason I adopted a Monorepo in LYStack.
LYStack's structure is:
LYStack/
├── apps/
│ ├── example-vite/
│ ├── example-rsbuild/
│ └── example-rsbuild-mpa/
├── packages/
│ ├── build-config/
│ ├── shared/
│ ├── services/
│ └── ui/
└── pnpm-workspace.yaml
The key point here is not that "Monorepo is more advanced," but that it takes shared capabilities out of the applications and puts them in a more stable location.
apps/*are specific applications that will change with business needs.packages/sharedis for cross-application shared types, constants, and environment variable reading.packages/servicesis for cross-application reusable request layer capabilities.packages/uiis for reusable base UI capabilities.packages/build-configis for build configuration abstraction.
This is much clearer than stuffing a bunch of public logic into one application's src/utils.
Because once a capability needs to be shared by multiple applications, it no longer belongs to a single app.
It should enter packages and become true infrastructure.
8. Behind the Directory Structure Is Actually Dependency Direction
Many arguments about directories are superficially about where to put a file, but are actually about dependency direction.
For example: Can services import router?
Many projects write code like this:
// services/request.ts
if (status === 401) {
router.push('/login');
}
It's very convenient in the short term, but will cause problems in the long run.
Because the services layer starts to depend on the application layer's router.
A better way is for the services layer to only expose injection points, and the application tells it how to handle a 401 at startup.
In LYStack, it's done like this:
configureServiceAuth({
getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
onAuthenticationFailure: () => {
// In a real project, this would redirect to the login page
},
});
This way, services doesn't need to know where the token comes from, nor how to redirect to the login page.
This is the relationship between directory structure and dependency direction.
If your directory structure allows lower layers to casually import from upper layers, the project will eventually become entangled.
A relatively healthy direction should be:
app/features -> services/shared/ui
services -> shared
ui -> shared
shared -> does not depend on the business layer
The lower the layer, the more stable it is, and the less it should know about upper-level business logic.
9. Don't Rush to Extract Shared Code at the Start
Many people have a misconception: to make a project look like it has "good architecture," they frantically extract shared code from the very beginning.
The result is that not long after the project starts, a pile of over-engineered designs appears:
- A component used only once is placed in
shared. - A hook used by only one page is made into a public hook.
- Unstable business rules are extracted into
utils. - A bunch of parameters are designed in advance for potential future reuse.
This is not architecture; it's a burden.
I highly recommend following a simple principle:
The first time, put it close. The second time you see duplication, note it. The third time, consider abstraction.
Making something public should be driven by demand, not by imagination.
A file moving from page-local to a business module, and then from a business module to shared, should happen gradually.
For example:
pages/order/list/FilterPanel.vue
If only the order list uses it, let it stay beside the page.
Later, if the order detail page also needs it, it can be moved to:
features/order/components/OrderFilterPanel.vue
Even later, if multiple businesses need a similar filter panel and the business rules have been completely stripped away, then consider:
shared/ui/FilterPanel.vue
Abstraction is not better the earlier it's done.
The prerequisite for abstraction is that you have already seen stable repetition.
10. Six Questions to Determine Where a File Should Go
In the future, if you don't know where a file should go, you can first ask six questions.
1. Is it bound to a specific business?
If it is bound, prioritize placing it in the business module.
Don't put it directly in components / utils just because it's a component or function.
2. Is it used by only one page?
If it's only used by one page, put it near that page.
Page-local code is not shameful; chaotic public extraction is the real trouble.
3. Can it be reused by multiple businesses without differentiation?
If it can, and it contains no business semantics, only then consider moving it into shared.
4. Does it depend on a runtime environment?
Code that depends on the browser, client, router, or UI framework should not be disguised as pure utils.
Give it a more explicit directory.
5. Does it belong to application startup assembly?
Plugin registration, authentication injection, error message injection, and global initialization should be consolidated into app/bootstrap, not scattered among components.
6. What is the reason for its future change?
If it changes because order rules change, keep it close to the order module. If it changes because the build tool changes, put it in the build layer. If it changes because UI specifications change, put it in the UI layer. If it changes because the environment reading mechanism changes, put it in the env layer.
The reason for change is the most easily overlooked, yet most important, question in directory design.
11. Finally
Directory structure is not a mystical art, nor a template competition.
It is essentially answering:
By what boundaries should the code in this project be organized so that future changes won't drag each other down?
components and utils are not unusable.
But if you throw every component with an unclear ownership into components, and every function with an unclear nature into utils, the project will definitely become harder and harder to maintain.
A truly good directory structure should allow anyone to see at a glance:
- What is application assembly
- What are business modules
- What are shared capabilities
- What is the service layer
- What is the base UI
- What is build or engineering configuration
Directory structure is not about making today's coding more pleasant, but about ensuring that six months from now, you, your colleagues, newcomers, and even AI, can all know where code should be placed, where to find it, what it can depend on, and what it cannot depend on.
If you've been writing business code for a few years but your projects become a mess as soon as you set them up, don't rush to switch frameworks.
Start by organizing your directory boundaries first.
In the next article, I want to continue the discussion:
How exactly should the front-end request layer be wrapped? Why does a global axios singleton become harder and harder to maintain?
I will continue to use the services design in LYStack to break down why the request layer should use dependency inversion, instead of hard-coding token, router, and UI messages all together.
Project Address: