JJ.
Back to Blog

How to Structure Your Vue.js Project Correctly

By Jiss Johnson 6 min read Architecture

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-vue is active and configured to enforce standard structural rules, such as vue/multi-word-component-names.
  • Strict Type Contracts: Turn on "strict": true inside 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.env casually inside UI components. Map your environmental variables inside a single file (e.g., @core/config/env.ts) to validate their presence during application bootstrap.
JJ

Jiss Johnson

Senior Vue.js Engineer helping companies build scalable frontend architectures. Open to new opportunities.