File-Based Architecture for Larger Vue Projects
AI generated
<v/>
{ }
Vue 3 · Architecture · Feature Slicing · Composables
File-Based Architecture for Larger Vue Projects
from feature slices to clean module boundaries

Anyone who builds Vue projects without a well-thought-out file structure ends up fighting unclear ownership and circular dependencies by the time a second developer joins. Feature-based Vue architecture with clearly defined layers and composables as its core solves this problem structurally, before it slows down everyday work.

15 min read Feature slices · Composables · Pinia · Vue Router · Module boundaries Vue 3 · Vite · TypeScript

1. Why classic folder structures fail in Vue

The classic Vue project structure with components/, views/, store/, services/ and utils/ as top-level folders works well for small applications with a single developer and a manageable scope. As soon as the project grows, these folders turn into drawers that everything gets stuffed into. A simple feature request then leads to files being created or changed in four different folders, a clear sign that the **Vue architecture** is organized by technical type rather than by responsibility.

The concrete problem: after a year, components/ holds thirty to fifty components with no recognizable relationship to one another. A new developer cannot tell which component belongs to which feature, which ones are shared and which are only used by a single view. At the same time, the Pinia store grows into a monolithic object holding state for completely unrelated features. File-based **Vue architecture** solves this problem by starting from a different premise: not the technical type but the business domain determines the folder structure.

2. Feature slicing: the core of file-based Vue architecture

Feature slicing means that every business feature gets its own folder under src/features/. A feature here is anything that represents a self-contained domain: features/auth/, features/catalog/, features/checkout/, features/user-profile/. Inside this feature folder live all the associated components, composables, stores, API calls and types. The result is a **Vue architecture** in which deleting or refactoring a feature comes with minimal side effects, because all the relevant files live in one place.

The boundary between features follows the principle of loose coupling: a feature must never directly access the internal code of another feature. Communication between features happens either through the shared/ layer, through event buses, or through a shared Pinia store that is explicitly marked as shared. This rule, consistently followed, prevents circular dependencies from forming, the most common maintenance problem in grown Vue projects without a well-thought-out **Vue architecture**.


// Recommended directory structure for scalable Vue 3 projects
src/
├── features/
│   ├── auth/
│   │   ├── components/       // LoginForm.vue, AuthGuard.vue
│   │   ├── composables/      // useAuth.ts, usePermissions.ts
│   │   ├── stores/           // authStore.ts
│   │   ├── api/              // authApi.ts
│   │   ├── types/            // auth.types.ts
│   │   └── index.ts          // public API of the feature
│   ├── catalog/
│   │   ├── components/
│   │   ├── composables/
│   │   ├── stores/
│   │   ├── api/
│   │   └── index.ts
│   └── checkout/
│       └── ...
├── shared/
│   ├── components/           // Button, Modal, Input, truly reusable
│   ├── composables/          // useDebounce, usePagination
│   ├── utils/                // formatDate, slugify
│   └── types/                // global.types.ts
├── app/
│   ├── router/               // route definitions, lazy imports
│   ├── stores/                // cross-feature stores only
│   └── App.vue
└── main.ts

3. Layers within a feature slice

Inside every feature folder there is a clear layered architecture that works from the outside in. The outermost layer is the Vue components in components/, which are purely presentational and contain as little logic as possible. Components call composables, read reactive data from them, and forward user input. This layer is easy to test with Testing Library because it has no direct dependency on API calls or global state. This separation makes the **Vue architecture** more resilient to refactorings in the data layer.

The middle layer consists of the composables: they hold local state, coordinate API calls through the API layer, and provide reactive data to the template. Composables are the right place for business logic that belongs neither in the component nor in the global store. The innermost layer is the API layer in api/: pure functions that trigger HTTP requests and return typed responses. They know nothing about Vue reactivity and are easy to test with regular unit tests. Every feature's index.ts exports only what other features or the router actually need, the internal structure stays private.

4. Composables as the core of business logic

Composables are the most powerful tool in Vue 3's **Vue architecture**. They replace mixins, which were the main mechanism for reusing logic in Vue 2, and solve their problems: no implicit naming conflicts, no unclear origin of reactive data, full TypeScript support. A well-written composable is a function that encapsulates ref, computed and Vue lifecycle hooks and exposes a clearly defined interface. By convention the name always starts with use, which immediately signals membership in the composables layer.

The most important design decision when writing composables is whether they hold their own state or receive state as a parameter. A composable that calls ref() internally creates a new state instance on every call, good for form logic that should be isolated per component instance. A composable that imports a Pinia store implicitly shares its state with every caller. In the **Vue architecture** it is important not to mix these two patterns: composables with local state for feature-internal logic, store-backed composables for cross-feature data.


// features/catalog/composables/useProductList.ts
// Composable with local state, each caller gets its own instance
import { ref, computed, onMounted } from 'vue'
import { fetchProducts } from '../api/catalogApi'
import type { Product, ProductFilter } from '../types/catalog.types'

export function useProductList(initialFilter: ProductFilter = {}) {
  // Local reactive state, not shared between callers
  const products = ref<Product[]>([])
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  const filter = ref<ProductFilter>(initialFilter)

  // Derived state, automatically updates when products or filter changes
  const filteredCount = computed(() => products.value.length)

  async function loadProducts() {
    isLoading.value = true
    error.value = null
    try {
      products.value = await fetchProducts(filter.value)
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Unknown error'
    } finally {
      isLoading.value = false
    }
  }

  function updateFilter(newFilter: Partial<ProductFilter>) {
    filter.value = { ...filter.value, ...newFilter }
    loadProducts()
  }

  // Lifecycle hook inside composable, no need to call in component
  onMounted(loadProducts)

  return { products, isLoading, error, filteredCount, updateFilter }
}

5. Shared layer: what may truly be shared

The shared/ layer is the most dangerous zone in any **Vue architecture**, because it can become a dumping ground for everything a developer doesn't want to assign to a specific feature. The rule for the shared layer must be stated clearly: a file belongs in shared/ only if it is used by at least three independent features and contains no feature-specific logic. A button, a modal, a pagination composable and a date formatter belong there. An API call that happens to be used by two features, on the other hand, belongs to the feature that carries primary responsibility for that data.

Shared components follow the principle of maximum genericity: they accept all variable content via props and slots and have no opinion about the domain. A shared/components/DataTable.vue doesn't know whether it displays products or orders, that is dictated by the caller through typed props. This abstraction pays off when rendering behavior can be adjusted in one central place without touching every feature component. The **Vue architecture** gains consistency this way and reduces duplicate code without compromising feature isolation.

6. Routing and lazy loading per feature

In a file-based **Vue architecture**, every feature defines its own routes and registers them with the central router. The classic pattern is a routes.ts file in the feature folder that exports an array of RouteRecordRaw objects. The central router in app/router/index.ts imports these arrays and merges them. The result is a router setup that needs only one import line for a new feature, without having to fundamentally touch the existing router configuration.

Lazy loading is simple and consistent to implement with this approach: every view component is loaded with a dynamic import function, so the Vite bundler automatically creates a separate chunk for each feature. The initial bundle becomes noticeably smaller, because only the code actually needed for the current route is loaded. In the **Vue architecture** this means: as a feature grows through new subpages, only that feature's chunk grows, the rest of the application stays unchanged. Route guards that access a feature's own authentication logic are likewise defined in that feature's own routes.ts.

7. Organizing Pinia stores cleanly

Pinia is the official state manager for Vue 3 and fits naturally into a feature-slicing **Vue architecture**. The basic rule: every feature store belongs in features/[name]/stores/ and contains only the state that belongs to that feature. Store definitions using Pinia's Composition API syntax are preferable, because they work seamlessly with composables and pass TypeScript types through without extra wrapper types.

Stores that need to be shared across features, such as a user store or a notification store, belong in app/stores/ and are explicitly marked as global. Feature stores may import global stores, but global stores must never import feature stores: this one-way street prevents circular dependencies in the **Vue architecture** at the store level. If a feature store grows too large and manages several unrelated areas of state, that's a clear signal to split the feature further or use composables to encapsulate state.


// features/auth/stores/authStore.ts
// Pinia store using Composition API syntax, type-safe and composable-friendly
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '../types/auth.types'
import { loginRequest, logoutRequest } from '../api/authApi'

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('auth_token'))

  // Getters
  const isAuthenticated = computed(() => token.value !== null && user.value !== null)
  const userDisplayName = computed(() => user.value?.name ?? 'Guest')

  // Actions
  async function login(email: string, password: string) {
    const response = await loginRequest({ email, password })
    token.value = response.token
    user.value = response.user
    localStorage.setItem('auth_token', response.token)
  }

  async function logout() {
    await logoutRequest()
    token.value = null
    user.value = null
    localStorage.removeItem('auth_token')
  }

  return { user, token, isAuthenticated, userDisplayName, login, logout }
})

8. Enforcing module boundaries and checking dependencies

The best **Vue architecture** is of little use if nobody enforces the defined boundaries. In small teams a documented convention is often enough, but from four or more developers onward an automated tool is recommended. ESLint with the eslint-plugin-import plugin can, with the right configuration, prevent Feature A from directly importing the internal components of Feature B. The rule: always import only through index.ts, never from subpaths of other features. An import like import X from '@/features/auth/components/LoginForm.vue' from another feature is a boundary violation.

It's also worth integrating madge or dependency-cruiser into the CI pipeline. These tools visualize and validate the project's dependency graph. A configuration that flags circular dependencies and forbidden cross-feature imports as errors prevents the **Vue architecture** from eroding over time. In the pull request you immediately see when a new feature import violates the defined module boundaries, and can course-correct before the pattern spreads across the entire codebase.

9. Architecture approaches compared

There are several well-known approaches to structuring Vue projects. The right choice depends on team size, feature count and long-term perspective.

Approach Structure Scales to Main problem
Type-based components/, views/, store/ 1-2 developers, <10 features Unclear ownership as it grows
Feature slicing features/[name]/… Team of 5-15 people Requires discipline on the shared layer
Monorepo (Nx/Turborepo) Separate packages per feature Enterprise, many teams High setup and tooling overhead
Nuxt modules Nuxt conventions + modules SSR projects with Nuxt Only makes sense with Nuxt
DDD layers Domain, Application, Infrastructure Complex domain logic Overhead for small teams

For most Vue projects that outgrow the prototype phase, feature slicing is the most pragmatic approach. It brings the benefits of a clear **Vue architecture** without the overhead of a full monorepo setup. The move to a monorepo approach makes sense when features are developed by different teams with different deployment cycles, only then does physical package separation pay off.

Mironsoft

Vue 3 architecture, frontend consulting and scaling advice

Is your Vue project growing without a clear architecture?

We analyze existing Vue projects, identify architectural weak points, and guide you step by step toward a feature-based structure, without interrupting ongoing operations.

Architecture review

Analysis of the existing Vue structure for dependencies and scaling obstacles

Refactoring plan

Step-by-step migration to feature slicing without a big-bang rewrite

Team workshop

Documenting architecture conventions and handing them over to the team

10. Summary

File-based **Vue architecture** with feature slicing solves the fundamental problem of grown Vue projects: unclear ownership, circular dependencies and poor maintainability. Every feature gets its own folder with components, composables, store, API layer and types. The shared layer contains only genuine, domain-free abstractions. Composables take over the business logic and keep local state cleanly separated from global Pinia state. Routing and lazy loading are organized per feature.

The decisive step is not the folder structure alone but enforcing the module boundaries: no cross-feature import bypassing internal components, no state in global stores that belongs to a single feature. Automation through ESLint rules and dependency-cruiser in the CI pipeline ensures that the **Vue architecture** is preserved long-term, even as the team grows and new developers join who don't know the original design decisions.

File-based Vue architecture, the essentials at a glance

Feature slicing

Every feature has its own folder under features/ with components, composables, store and API. No cross-feature imports bypassing index.ts.

Composables

Composables encapsulate business logic and reactive state. Local state via ref(), global state via Pinia stores, never mix them.

Shared layer

Only abstractions used by at least three features and containing no feature logic belong in shared/.

Enforce boundaries

ESLint rules and dependency-cruiser in CI prevent module boundaries from eroding over time, automatically, without manual code reviews.

11. FAQ: File-Based Architecture for Larger Vue Projects

1At what project size does feature slicing pay off?
From two developers and more than five distinguishable features. Introducing it early doesn't hurt, too late means refactoring under pressure.
2How do you migrate an existing project?
Step by step, feature by feature. New features go directly into the new structure. No big-bang rewrite.
3Composable vs. Pinia store?
Composable = its own instance per caller. Pinia = singleton. Pinia for cross-feature, composables for feature-internal.
4May Feature A import the store of Feature B?
No. Shared state belongs in app/stores/ or is communicated via events.
5Routing in feature slicing?
Every feature exports routes.ts. The central router imports and merges the arrays. Lazy loading automatically creates feature chunks.
6What belongs in shared?
Generic UI components, domain-free composables, format utils. No API code, no feature logic.
7Shared layer becoming a dumping ground?
Three-feature rule: only once three features actually use it does it go into shared. Leave it in the feature first, extract only when there's real demand.
8Does it work with Nuxt?
Yes. Feature folders under src/features/, Nuxt auto-imports only for true globals. Both approaches complement each other.
9What is the index.ts in the feature folder?
The feature's public API. Exports only what others actually need. Internal details stay private.
10Tools for architecture boundaries?
eslint-plugin-import, dependency-cruiser in CI, madge for visualization. Make boundary violations visible and automatically blockable in the PR.