Structuring a Large Vue App: A Feature-Based Folder Architecture

Structuring a Large Vue App: A Feature-Based Folder Architecture

Why the default components/composables/stores split breaks down at scale, and how a feature-first structure fixes it.

Reza Baar

Reza Baar

September 9, 2026

Most Vue projects start with the default structure: a components/ folder, a composables/ folder, a stores/ folder. This works great until it doesn't. Once your app grows past 50 or so components, components/ becomes a graveyard where UI elements go to be forgotten and duplicated, and fixing a single feature means jumping between four top-level folders. In this post, we'll look at why the type-based split breaks down, how a feature-based structure fixes it, and when each approach is the right call.

This is a community convention, or maybe my convention. It is not an official recommendation. The Vue.js website doesn't prescribe a project structure beyond the style guide (naming rules, multi-word component names, and so on). Feature-based organization is a popular pattern borrowed from the broader frontend world (it's common in React and Angular circles and rooted in domain-driven design), and plenty of experienced Vue developers use it. But it's one option among several, not the blessed answer. Treat everything here as a reasonable default to weigh, not a rule.

Vue Has No Folder Conventions

Plain Vue (Vue + Vite) has no folder conventions. Vue doesn't scan directories or auto-import anything like Nuxt does. You import every component and composable explicitly, and you could name your folders anything you like. Nothing about the structure is enforced.

That's freeing but also means the responsibility is entirely yours. The components/, composables/, stores/ layout is just a convention people adopted, not something the framework strictly requires. Since nothing is enforced, you're free to organize however serves your project best, which is exactly why it's worth thinking about deliberately rather than defaulting.

The Problem with Organizing by Type

The default structure organizes files by what they are: all components together, all composables together, all stores together. Here's what that looks like as an app grows:

src/
  components/
    UserAvatar.vue
    UserProfileForm.vue
    BillingSummary.vue
    BillingHistoryTable.vue
    InvoiceRow.vue
    ProjectCard.vue
    ProjectBoard.vue
    ... 80 more files
  composables/
    useUser.ts
    useBilling.ts
    useInvoices.ts
    useProjects.ts
    ... 30 more files
  stores/
    user.ts
    billing.ts
    projects.ts
    ... more files

The issue isn't obvious at ten files. It shows up at a hundred. When you need to fix a bug in the billing flow, the billing code is scattered across three top-level folders. You open components/ and scroll past project components and user components to find the billing ones. You jump to composables/ and do it again. You jump to stores/ and do it a third time.

This is cognitive load. The files that change together don't live together, so every task involves hunting across the codebase. It also makes ownership fuzzy: when everything is in shared folders, no one clearly owns the billing feature.

Organizing by Feature Instead

A feature-based structure flips the axis. Instead of grouping by what a file is, you group by what feature it belongs to. Everything related to billing lives in one place:

src/
  features/
    billing/
      components/
        BillingSummary.vue
        BillingHistoryTable.vue
        InvoiceRow.vue
      composables/
        useBilling.ts
        useInvoices.ts
      stores/
        billing.ts
      types.ts
      index.ts
    user/
      components/
        UserAvatar.vue
        UserProfileForm.vue
      composables/
        useUser.ts
      stores/
        user.ts
      types.ts
      index.ts
    projects/
      components/
        ProjectCard.vue
        ProjectBoard.vue
      composables/
        useProjects.ts
      stores/
        projects.ts
      types.ts
      index.ts

Now the billing bug is a single folder. Everything you need (the components, the data logic, the store, the types) is right there. You aren't jumping between global folders, and it's clear that this folder is the billing feature.

What Lives Outside Features

Not everything belongs to a feature. Three categories sit outside the features/ directory.

Shared UI is your design-system layer: buttons, inputs, modals, and other atomic components that have no business logic. Any feature can use them.

src/
  shared/
    ui/
      BaseButton.vue
      BaseInput.vue
      BaseModal.vue
    composables/
      useClickOutside.ts
      useMediaQuery.ts

Core is global infrastructure: the API client, authentication, telemetry, router setup. This is the plumbing every feature depends on but that doesn't belong to any single feature.

src/
  core/
    api/
      client.ts
    auth/
      useAuth.ts
    router/
      index.ts

Pages (or views/) are the route-level components that compose features together. A dashboard page might pull in components from the billing, user, and projects features to build a single screen.

src/
  pages/
    DashboardPage.vue
    BillingPage.vue
    SettingsPage.vue

Putting it together, the top level looks like this:

src/
  core/          # global infrastructure
  shared/        # design-system UI and generic composables
  features/      # business features, each self-contained
  pages/         # route-level components that compose features
  main.ts

The Public API of a Feature

This pattern makes feature folders actually maintainable: each feature exposes a public API through an index.ts barrel file. Other parts of the app import from the feature, not from files deep inside it.

// features/billing/index.ts
export { default as BillingSummary } from './components/BillingSummary.vue';
export { useBilling } from './composables/useBilling';
export type { Invoice, BillingPlan } from './types';

Now a page imports from the feature's front door:

// pages/BillingPage.vue
<script setup lang="ts">
import { BillingSummary, useBilling } from '@/features/billing';

const { plan, invoices } = useBilling();
</script>

This does two things. It keeps the feature's internal structure private, so you can rearrange files inside billing/ without breaking every import across the app. And it makes dependencies between features explicit: if the projects feature imports from billing, that import goes through billing's public API, and you can see the coupling clearly.

However, because barrel files aren't free. In your production build, Rollup (or Rolldown in Vite 8) can usually tree-shake through a barrel fine, so unused exports still get dropped, as long as the re-exported modules are side-effect-free. The cost shows up at dev time. Vite serves modules unbundled during development, so importing one thing from a big barrel makes the dev server pull in and transform everything that barrel re-exports. For a large feature with a huge index.ts, that can slow cold starts and HMR. Barrels also make it easier to create circular dependencies by accident. So keep barrels reasonably small, and if dev startup gets sluggish, importing directly from the specific file instead of through the barrel is the usual fix. The public-API benefit is real, but it's a tradeoff, not a pure win.

The Rule That Keeps It Clean

A feature-based structure only stays clean if you follow one rule: features don't reach into each other's internals. A feature can depend on shared/ and core/ freely. It can depend on another feature only through that feature's public index.ts. It never imports a file from deep inside another feature.

If you find two features constantly reaching into each other, that's a signal. Either the shared piece belongs in shared/, or the two features are really one feature that should be merged. This rule turns your folder structure into a lightweight architecture check.

Avoiding Over-Nesting

One trap with feature folders is going too deep. A five-level path like features/billing/components/invoices/rows/InvoiceRow.vue makes navigation harder, not easier. Deep nesting fights your editor's quick-open, since you have to remember the whole path.

Keep it flat within reason. If a feature is small, you don't need the full components/composables/stores sub-structure. A handful of files at the feature root is fine:

features/
  notifications/
    NotificationBell.vue
    NotificationList.vue
    useNotifications.ts
    types.ts
    index.ts

Add the sub-folders only when a feature grows enough to need them. The structure should serve the feature's size, not impose ceremony on small ones.

Does Structure Affect Performance?

My short answer is “no”. Your folder structure has essentially no effect on your production bundle, tree-shaking, or bundle size.

Bundlers build their output from the module graph, meaning what imports what, not where files sit on disk. Tree-shaking works on ES module imports and exports: it drops any export nothing references, as long as the module is side-effect-free. Code-splitting happens at dynamic import() boundaries and lazy-loaded routes. None of that reads your folder names. A feature-based app and a type-based app with the same code compile to identical bundles. So "which structure is faster" is the wrong question. Structure is a developer-experience and maintainability decision, not an optimization one.

The things that actually affect your bundle are separate from folder layout:

  • Static vs dynamic imports. A static import pulls code into the main chunk. A dynamic import() or a lazy route creates a separate chunk loaded on demand. This is the real lever for initial bundle size, and it's folder-independent.
  • Barrel files at dev time. Covered above: oversized index.ts barrels can slow the dev server, though they usually don't hurt the production bundle.
  • Side-effect-free modules. Tree-shaking can only drop code it's sure is safe to remove. Modules with side effects (or a library's sideEffects setting in its package.json) can keep code in the bundle.

When to Use Which Structure

Feature-based isn't automatically right for every project. Here's a way to decide.

Stick with the default type-based structure when your app is small (under ~30-40 components), when it's a prototype or a project with a short lifespan, or when there's really only one feature. The overhead of feature folders isn't worth it for a to-do app.

Move to feature-based when the app has grown past the point where components/ is hard to scan, when multiple developers are working in parallel and stepping on each other, or when you can clearly name distinct business domains (billing, projects, users, reporting). The clearer the domains, the more this structure pays off.

The progression is natural: start with the default, move to features when the default hurts, reach for a monorepo when you have multiple apps. Don't skip ahead to complexity you don't need yet.

Migrating an Existing App

If you're moving an existing app to feature-based, you don't have to do it all at once. Create the features/ directory alongside your existing folders and move one feature at a time. Pick a well-bounded feature, move its files, add the index.ts, update the imports, and verify it works. Then do the next one.

Path aliases make this much smoother. Set up an @/ alias so imports don't break when files move:

// vite.config.ts
import { fileURLToPath, URL } from 'node:url';

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

With aliases, imports reference @/features/billing regardless of where the importing file lives, so moving files around doesn't cascade into import churn.

Key Takeaways

  • Feature-based structure groups everything for a feature (components, composables, stores, types) in one folder
  • Keep shared/ for design-system UI, core/ for global infrastructure, and pages/ for route-level composition
  • A public API through an index.ts barrel keeps internals private, but watch barrel size since it can slow the dev server
  • The key rule: features depend on shared and core freely, but on other features only through their public API

Conclusion

Feature-based organization is one popular way to keep a large Vue app navigable. Its real value is making the files that change together live together, so fixing a bug or building a feature happens in one folder instead of four. It won't make your app faster, and it's overkill for small projects. Start with the default structure, and when scanning components/ starts to hurt, consider reorganizing by feature. Weigh it as an option, and pick what keeps your team productive.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.