How to Structure Your Vue.js Project Correctly
The default scaffold generated by npm create vue@latest is perfect for
small-scale applications or initial prototypes. However, as business logic expands,
keeping a flat components/ and views/ architecture inevitably
leads to tightly coupled modules, structural spaghetti, and severe merge conflicts in
multi-developer teams.
To build a codebase capable of scaling to hundreds of routes and components, we must transition from a technical-type split (grouping files strictly by what they are, like components vs. stores) to a domain-driven, multi-scale architecture.
Multi-Scale Architecture Blueprints
Depending on the scale and complexity of the application you are building, your directory structure should adapt. Below are two proven blueprints for structuring an enterprise-grade Vue 3 codebase using the Composition API and Pinia.
1. The Mid-Scale Architecture (Feature-Oriented)
For medium-sized applications, we retain a unified src directory but group
layout blocks and reusable logic clearly to decouple the global scope.
src/ ├── assets/ # Static files (global CSS, typography, uncompiled raw images) ├── components/ # Strict global UI elements (BaseButton, BaseInput, AppModal) ├── composables/ # Domain-agnostic reactive utilities (useDebounce, useLocalStorage) ├── layouts/ # Page shell wrappers (AuthLayout.vue, DefaultLayout.vue) ├── router/ # Central routing configuration and navigation guards ├── stores/ # Global cross-cutting Pinia stores (theme, notifications) └── views/ # View components mapped directly to router paths
2. The High-Scale Architecture (Domain-Driven Modules)
For massive enterprise platforms or multi-tenant systems, splitting by technical type breaks down. Instead, we encapsulate features into isolated, domain-driven modules.
src/
├── main.ts # Application bootstrapper
├── App.vue # Root component
├── core/ # App-wide engines, singletons, and layer configurations
│ ├── http/ # Axios/Fetch client interceptors and error handlers
│ ├── router/ # Core routing engine that stitches module routes together
│ └── guards/ # Global navigation auth/role guards
├── shared/ # The foundational layer shared across all domains
│ ├── components/ # Agnostic UI design-system primitives (BaseTable.vue)
│ ├── composables/ # Global composition hooks (useNetworkStatus.ts)
│ └── utils/ # Pure Javascript/Typescript helper functions
└── modules/ # Isolated domain boundaries
├── auth/ # Authentication Domain
│ ├── components/ # Private components (LoginForm.vue, ResetPasswordCard.vue)
│ ├── composables/# Domain-specific hooks (useAuthMutation.ts)
│ ├── store/ # Encapsulated state (authStore.ts)
│ ├── views/ # Dedicated view wrappers (LoginView.vue)
│ └── auth.routes.ts # Isolated sub-routing definitions
└── dashboard/ # Analytics & Operations Domain
Architectural Rules of Engagement
Structuring directories is only half the battle; enforcing boundaries between them prevents technical debt. Implement these three core principles in your codebase:
Strict Downward Dependency Flow
Components or files inside the global shared/ directory must
never import anything from the
modules/ directory. shared/ is completely decoupled and
unaware of your application's domain logic.
Similarly, an isolated module (e.g., modules/auth/) must never cross-import
directly from another module (e.g., modules/dashboard/). If two domains
require the exact same component or logic, that asset must be refactored and promoted up
into the shared/ layer.
The Index Barrel Pattern
To keep module boundaries clean, utilize an index.ts file at the root of
each module folder to act as a public API gateway.
// modules/auth/index.ts
export { default as authRoutes } from './auth.routes'
export { useAuthStore } from './store/authStore'
When other layers of the application need access to authentication items, they interface purely through the barrel file. This allows you to refactor files inside the auth folder freely without breaking external imports.
Tooling Configurations for Clean Codebases
Explicit Path Aliasing
Deep relative paths like ../../../../shared/components/BaseButton.vue make
code fragile and painful to read. Configure path aliases to establish explicit layers.
Update your vite.config.ts:
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('./src/core', import.meta.url)),
'@shared': fileURLToPath(new URL('./src/shared', import.meta.url)),
'@modules': fileURLToPath(new URL('./src/modules', import.meta.url))
}
}
})
To ensure your IDE provides auto-completion and TypeScript tracks types accurately,
mirror these aliases in your tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@core/*": ["src/core/*"],
"@shared/*": ["src/shared/*"],
"@modules/*": ["src/modules/*"]
}
}
}
Strict Internal File Anatomy
Consistency within a single .vue file is just as vital as directory
structure. When leveraging the speed of <script setup>, establish a
strict vertical layout sequence so developers immediately know where to look.
<script setup lang="ts">
// 1. External Third-Party Imports
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
// 2. Global Internal Imports (Shared Components / Core Utilities)
import BaseButton from '@shared/components/BaseButton.vue'
import { formatCurrency } from '@shared/utils/formatter'
// 3. Domain/Module Level Imports
import { useAuthStore } from '@modules/auth'
// 4. Macro Definitions (Props, Emits, Slots)
const props = defineProps<{
title: string
amount: number
}>()
const emit = defineEmits<{
(e: 'execute-action', id: string): void
}>()
// 5. Reactive State & Stores
const route = useRoute()
const authStore = useAuthStore()
// 6. Computed Properties
const displayAmount = computed(() => formatCurrency(props.amount))
// 7. Functional Methods
function handleAction() {
emit('execute-action', route.params.id as string)
}
// 8. Lifecycle Hooks
onMounted(() => {
// Initialize non-reactive analytical data or event listeners
})
</script>
<template>
<div class="dashboard-card">
<h3>{{ title }}</h3>
<p>{{ displayAmount }}</p>
<BaseButton @click="handleAction">Proceed</BaseButton>
</div>
</template>
<style scoped>
.dashboard-card {
padding: 1.5rem;
border-radius: 8px;
}
</style>
The Day-1 Architectural Checklist
Before writing any core feature code on a new Vue 3 project, verify that the absolute roots of your architecture are established to prevent structural drift later on:
-
Linting Guardrails: Ensure
eslint-plugin-vueis active and configured to enforce standard structural rules, such asvue/multi-word-component-names. -
Strict Type Contracts: Turn on
"strict": trueinside your TypeScript configuration. This mandates that your data models across modular stores are safe and fully typed from inception. -
Environment Centralization: Avoid accessing
import.meta.envcasually inside UI components. Map your environmental variables inside a single file (e.g.,@core/config/env.ts) to validate their presence during application bootstrap.
Jiss Johnson
Senior Vue.js Engineer helping companies build scalable frontend architectures. Open to new opportunities.