Folders That Survive Growth

Group a Vue project by feature instead of by file type, and use the @ alias so moving a file does not rewrite its imports.

Syntaximport StatusBadge from '@/shared/components/StatusBadge.vue'

Every new Vue project starts with src/components and src/views. That arrangement holds for ten files. At a hundred, one change touches four folders, and no folder tells you what the application does.

Group by feature

Put everything a feature needs in one folder: its components, its composables, its API calls, its types. A composable is a reusable function that uses Vue's reactivity, covered in Stage 2. So src/features/invoices/ holds components/, composables/, api.ts and types.ts. Removing the feature becomes removing the folder.

Where shared code goes

Code that two or more features use moves to src/shared/. The views the router mounts stay in src/views/, because routing is the one concern that is application-wide. Keep a view thin: it composes feature components and little else.

The import tangle

Deep folders produce imports like ../../../shared/components/StatusBadge.vue. Projects created with npm create vue@latest ship an alias for exactly this: @/ means src/. These two are the same file:

  • import StatusBadge from '../../../shared/components/StatusBadge.vue'
  • import StatusBadge from '@/shared/components/StatusBadge.vue'

The alias form survives a file move; the relative form does not. One rule keeps the graph honest: a feature may import from shared, never from another feature. When two features need the same thing, that thing belongs in shared. The component below sits inside a feature folder and reaches shared code through the alias.

Example

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { useInvoiceTotals } from '@/features/invoices/composables/useInvoiceTotals'
import type { Invoice } from '@/features/invoices/types'

const props = defineProps<{ invoices: Invoice[] }>()

const { formattedTotal } = useInvoiceTotals(computed(() => props.invoices))
</script>

<template>
  <ul>
    <li v-for="invoice in props.invoices" :key="invoice.id">
      <strong>{{ invoice.number }}</strong>
      <status-badge :status="invoice.status" />
    </li>
  </ul>
  <p>Total outstanding: {{ formattedTotal }}</p>
</template>

<style scoped>
ul {
  list-style: none;
  padding: 0;
}
</style>

When to use it

  • Splitting a src/components folder that has grown past fifty files into feature folders.
  • Deciding whether a new button component belongs to one feature or to src/shared.
  • Adding a second path alias so test fixtures import by name rather than by relative path.

More examples

The alias, as the bundler sees it

This is the vite.config.ts that npm create vue@latest generates, with Vite 8. resolve.alias is what makes '@/shared/...' resolve at build and dev time. Reach for this file when you want a second alias, for instance '#test' pointing at a fixtures folder.

Example · ts
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

The same alias, as the type checker sees it

vue-tsc reads tsconfig, not vite.config, so the alias has to be repeated here as a path mapping. If an editor underlines an '@/' import in red while the page loads correctly in the browser, this is the file that is out of step.

Example · json
// tsconfig.app.json
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

A layout that reads like the product

Each feature folder is self-contained, and src/shared holds only what more than one feature uses. Use this shape once a project has more than two or three screens; below that, the default components and views folders are not yet a problem.

Example · text
src/
  api/
    client.ts
  features/
    invoices/
      components/InvoiceList.vue
      composables/useInvoiceTotals.ts
      api.ts
      types.ts
    settings/
      components/ProfileForm.vue
      api.ts
  shared/
    components/StatusBadge.vue
    composables/useClipboard.ts
  views/
    InvoicesView.vue
    SettingsView.vue
  router/index.ts
  stores/auth.ts
  App.vue
  main.ts

Discussion

  • Be the first to comment on this lesson.