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:
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:
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.
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:
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:
// 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:
{
"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" }
]
}]
}
}
Routing Mirrors The Feature Folders
If you're using Vue Router, the route layer is a thin shell that imports from features:
// 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 components —
CheckoutForm.vue,OrderSummary.vue. The Vue style guide is firm on this. useprefix for composables —useCart.ts,useDebounce.ts. Lints happy, humans happy.- kebab-case for routes —
/order-history. Matches URLs. - Avoid
index.vuefor 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:
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:
- Group by feature, not by type.
- Co-locate tests, styles, and helpers with the code they belong to.
- Each feature has a public
index.ts. External code imports only from there. - Features depend on
shared/. Features do not depend on each other. - 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.


