The first Vue 3 app I worked on had three folders: components/, composables/, views/. It worked great for about six months. Then we hit 30,000 lines of code, four engineers, and a sprawling components/ folder where nobody could find anything. Every PR moved files around. The code review was as long as the feature.

The problem wasn't Vue. The problem was that the structure was organised by what kind of file something was, not by what feature it belonged to. Once we flipped that, half the friction disappeared. This article is the version of that lesson I wish I'd read earlier.

Type Folders Vs Feature Folders

Two competing philosophies, and only one of them scales:

Text
type-based                  feature-based
src/                        src/
  components/                 features/
    CheckoutForm.vue            checkout/
    OrderSummary.vue            search/
    SearchInput.vue             billing/
    InvoiceTable.vue          shared/
  composables/                  ui/
    useCart.ts                  composables/
    useSearchQuery.ts
  stores/
    cart.ts
  utils/
    formatPrice.ts

The type-based version looks tidy on day one. By month six, finding "everything related to checkout" means opening four folders and grepping. A change to checkout touches components/CheckoutForm.vue, composables/useCart.ts, stores/cart.ts, utils/formatPrice.ts — and you have to remember where each one lives.

Feature-based folders flip the question. Each feature owns its components, composables, store, types, tests, and styles in one place:

Text
src/features/checkout/
  components/
    CheckoutForm.vue
    OrderSummary.vue
  composables/
    useCart.ts
    useCheckoutFlow.ts
  stores/
    cart.ts
  api/
    checkoutClient.ts
  types.ts
  index.ts          ← the public API

Now "where does the checkout code live?" has one answer. New engineers can find work without a map. Deleting a feature is one folder away. The whole shape rewards local thinking.

Co-location Is The Real Win

Co-location means: tests, styles, and small helpers live next to the file they belong to.

Text
features/billing/components/
  InvoiceTable.vue
  InvoiceTable.spec.ts
  InvoiceTable.module.css
  invoice-utils.ts

There's no tests/ folder. No styles/ folder. If you're editing InvoiceTable.vue, every related file is one keystroke away. When you delete the component, you delete the test in the same commit.

The rule that earned its keep: if a file is only used by one other file, it should sit next to that file. The moment a second feature needs it, it moves up to the shared layer.

The Shared Layer

The other half of the structure is the layer below features:

Text
src/shared/
  ui/               ← design system primitives (Button, Modal, Input)
  composables/      ← cross-feature composables (useDebounce, useMediaQuery)
  lib/              ← api client, logger, analytics, formatters
  types/            ← cross-feature types

Everything in shared/ is allowed to be imported by anything. Everything in features/ is allowed to import from shared/ and from itself. Features should not import from other features. That's the single rule that keeps the dependency graph clean.

If features/checkout needs something from features/cart, that's a smell. Either the thing belongs in shared/, or cart should expose it through its public API and you re-think whether checkout and cart are really separate features.

Public APIs Per Feature

Every feature has an index.ts that re-exports only what's meant to be used from outside:

TypeScript
// features/checkout/index.ts
export { default as CheckoutForm } from './components/CheckoutForm.vue'
export { useCart } from './composables/useCart'
export type { Cart, CartItem } from './types'

External code imports from features/checkout, never from features/checkout/components/CheckoutForm.vue. This single rule lets you reorganise the internals of a feature without breaking anything outside it.

Tools like ESLint's no-restricted-imports, or path-based rules in eslint-plugin-boundaries, can enforce this automatically:

JSON
{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": [
        { "group": ["@/features/*/!(index)"], "message": "Import from feature index, not internals" },
        { "group": ["@/features/*/internal/*"], "message": "Don't reach into a feature's internals" }
      ]
    }]
  }
}

A side-by-side view of the same set of files in two layouts. Left: a flat type-based tree where one feature's code is split across four sibling folders. Right: a feature-based tree where the same code lives in a single folder per feature, with a shared/ layer below.
Same files, two layouts. The right one stays navigable.

Routing Mirrors The Feature Folders

If you're using Vue Router, the route layer is a thin shell that imports from features:

TypeScript
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  {
    path: '/checkout',
    component: () => import('@/features/checkout').then(m => m.CheckoutForm),
  },
  {
    path: '/search',
    component: () => import('@/features/search').then(m => m.SearchPage),
  },
]

Pages in the route layer should be small — load data, render the feature's main component, optionally handle errors. The actual UI lives in the feature. That separation lets you render the same feature from multiple routes without duplication, and it gives you natural code-splitting for free (each feature lazy-loads as a chunk).

In Nuxt, pages/ plays the same role: thin route components that compose feature pieces. Resist the temptation to put real logic in pages/.

What "Big" Means In Practice

A few rough thresholds that hold up across projects:

  • Up to ~5,000 lines: type-based folders are fine. Don't over-engineer.
  • 5k–50k lines: feature folders start paying off. Move now or refactor later.
  • 50k+ lines: feature folders are mandatory. ESLint boundary rules become genuinely useful. Some teams adopt a fuller layered scheme — app → pages → widgets → features → entities → shared, where each layer can only import from the layers below it (this is Feature-Sliced Design, a framework-agnostic methodology that grew out of the Russian frontend community and is now widely used across React and Vue).

Don't migrate prematurely. A three-week refactor on a 2,000-line app pays off only if you're sure it'll grow. But once it's growing, the cost of not migrating doubles every year.

Naming Matters More Than You Think

A few conventions that scale well in Vue specifically:

  • PascalCase for componentsCheckoutForm.vue, OrderSummary.vue. The Vue style guide is firm on this.
  • use prefix for composablesuseCart.ts, useDebounce.ts. Lints happy, humans happy.
  • kebab-case for routes/order-history. Matches URLs.
  • Avoid index.vue for components. It's allowed, but <CheckoutForm /> is much easier to find in stack traces than <index />.
  • Singular for entity types, plural for collections. Cart, CartItem, cartItems.

These look small. They become important when you're skimming a stack trace at 2 a.m.

A Worked Example: The Search Feature

A real-world feature, structured the way it would land in a 50k-line app:

Text
features/search/
  components/
    SearchInput.vue
    SearchResults.vue
    SearchFilter.vue
    SearchInput.spec.ts
  composables/
    useSearchQuery.ts
    useSearchHistory.ts
  api/
    searchClient.ts
  utils/
    highlightMatch.ts
  types.ts
  index.ts

The public index.ts exports SearchPage, useSearchQuery, and the Result type — that's all. Internals are free to refactor. Tests live next to the components they test. Each file has one job. A new engineer asked to "make the search faster" goes to one folder, reads four files, and ships.

What This Doesn't Solve

Folder structure is a precondition, not a substitute. It won't fix:

  • Tangled state management (covered in the Pinia/state articles).
  • Components that try to do everything at once (split them).
  • Cross-feature coordination — that needs a shared "orchestrator" layer or a parent route.
  • Circular dependencies inside a feature — audit imports periodically.

But getting the layout right makes all four problems easier to address, because the boundaries are visible. You can see when a feature is reaching into another's internals — it's right there in the import path.

The Smallest Set Of Rules

If you take five rules from this:

  1. Group by feature, not by type.
  2. Co-locate tests, styles, and helpers with the code they belong to.
  3. Each feature has a public index.ts. External code imports only from there.
  4. Features depend on shared/. Features do not depend on each other.
  5. The route layer is thin. The feature does the work.

That's most of the structural advice that's actually useful for Vue. Everything else — exact folder names, whether you keep types in a types.ts or co-locate them — is taste. The five rules above survive style preferences and team rotations.