Feature folders vs type folders, composable naming conventions, where to put shared types, how to structure your Pinia stores, the barrel file debate settled, and the folder structure decision that the team regrets the least six months later.
Every Vue 3 project starts the same way. A components/ folder, a views/ folder, a stores/ folder, a composables/ folder. Clean and sensible. Then the project grows. The components/ folder has 80 files. The composables/ folder has 40 files. Nobody remembers what useProjectData.ts does or whether it’s different from useProjects.ts. A new developer joins and spends two days building a component that already exists under a slightly different name. The structure that made sense at 20 components is a maze at 80.
This post is about the structure decision that doesn’t cause that outcome. Not the one that sounds best in a README, and not the one that satisfies the most architectural purity — the one that a team of three to eight developers can navigate six months after the initial choices were made, when the original author is no longer the only person who knows where things live.
The Two Structures and Why One Fails
Every Vue project lands somewhere between two structural philosophies:
Type-based structure: Feature-based structure:
────────────────────────────── ──────────────────────────────
src/ src/
├── components/ ├── features/
│ ├── Button.vue │ ├── projects/
│ ├── Modal.vue │ │ ├── components/
│ ├── ProjectCard.vue │ │ ├── composables/
│ ├── TaskList.vue │ │ ├── stores/
│ ├── UserAvatar.vue │ │ └── types/
│ └── ... (80 more) │ ├── tasks/
├── composables/ │ │ ├── components/
│ ├── useProjects.ts │ │ ├── composables/
│ ├── useAuth.ts │ │ └── stores/
│ └── ... (40 more) │ └── billing/
├── stores/ │ ├── components/
│ ├── projects.ts │ ├── composables/
│ ├── tasks.ts │ └── stores/
│ └── auth.ts └── shared/
└── types/ ├── components/
└── ... ├── composables/
└── types/
Type-based structure is natural at the start. It fails when the project grows because the grouping principle — “things of the same type” — gives you no information about what a file does. components/ProjectCard.vue and components/TaskList.vue are grouped together because they’re both .vue files, not because they’re related in any meaningful way. Finding all the code related to the projects feature means hunting across components/, composables/, stores/, and types/.
Feature-based structure is harder to set up initially but pays back over time. All code related to a feature lives together. Deleting a feature means deleting one folder. Adding a developer to a feature means showing them one folder. The grouping principle — “things that belong to the same feature” — gives you semantic information about every path you see.
The failure mode of feature-based structure is the shared/ folder growing without discipline until it’s as large and as undifferentiated as the original components/ folder. The rule that prevents this: something goes in shared/ when it’s used by two or more features, not when the developer isn’t sure where to put it.
The Structure That Works
src/
├── features/
│ ├── projects/
│ │ ├── components/
│ │ │ ├── ProjectCard.vue
│ │ │ ├── ProjectList.vue
│ │ │ └── ProjectStatusBadge.vue
│ │ ├── composables/
│ │ │ ├── useProject.ts ← single project operations
│ │ │ └── useProjectList.ts ← collection operations
│ │ ├── stores/
│ │ │ └── projectStore.ts
│ │ ├── types/
│ │ │ └── project.ts
│ │ └── index.ts ← feature's public API
│ ├── tasks/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── stores/
│ │ ├── types/
│ │ └── index.ts
│ └── billing/
│ ├── components/
│ ├── composables/
│ ├── stores/
│ ├── types/
│ └── index.ts
├── shared/
│ ├── components/
│ │ ├── ui/ ← design system atoms
│ │ │ ├── Button.vue
│ │ │ ├── Modal.vue
│ │ │ ├── Input.vue
│ │ │ └── Badge.vue
│ │ └── layout/ ← page-level layout components
│ │ ├── AppHeader.vue
│ │ ├── AppSidebar.vue
│ │ └── AppFooter.vue
│ ├── composables/
│ │ ├── useAuth.ts
│ │ ├── useToast.ts
│ │ └── usePagination.ts
│ ├── stores/
│ │ └── authStore.ts
│ └── types/
│ ├── api.ts ← API response shapes
│ ├── pagination.ts
│ └── common.ts ← shared primitives
├── pages/ ← Inertia pages or router views
│ ├── Dashboard.vue
│ ├── projects/
│ │ ├── Index.vue
│ │ ├── Show.vue
│ │ └── Create.vue
│ └── settings/
│ └── Index.vue
├── router/
│ └── index.ts
├── plugins/
│ └── index.ts
└── App.vue
The index.ts Feature Barrel — the Right Use of Barrel Files
The barrel file debate usually goes: “barrel files cause circular imports and make tree-shaking harder.” Both concerns are real in large barrel files that re-export everything from a deep module tree. The feature-level index.ts is a different pattern — it’s a deliberate public API for the feature, not an automatic re-export of everything.
// src/features/projects/index.ts
// What the projects feature exposes to the rest of the application
// Everything else is internal to the feature
export { default as ProjectCard } from './components/ProjectCard.vue'
export { default as ProjectList } from './components/ProjectList.vue'
export { useProject } from './composables/useProject'
export { useProjectList } from './composables/useProjectList'
export { useProjectStore } from './stores/projectStore'
export type { Project, ProjectStatus } from './types/project'
// Intentionally NOT exported:
// ProjectStatusBadge — internal component, used only within the feature
// useProjectFilters — internal composable, used only by useProjectList
Imports from outside the feature always go through the index:
// ✅ Import from the feature's public API
import { ProjectCard, useProject } from '@/features/projects'
// ❌ Import from the feature's internals
import { ProjectCard } from '@/features/projects/components/ProjectCard.vue'
The second form bypasses the feature’s public API contract. When ProjectCard moves or is renamed, imports from inside the feature update automatically; imports that went directly to the internal path break and need to be found and updated manually. The index.ts is the abstraction boundary.
The circular import concern is valid only when feature A’s index.ts imports from feature B’s index.ts and vice versa. The rule that prevents this: features don’t import from other features. Features import from shared/. If feature A genuinely needs something from feature B, that thing belongs in shared/.
Composable Naming: The Convention That Ends Arguments
The naming convention for composables is the single most impactful decision for a team’s ability to navigate the codebase. Two patterns exist:
// Pattern 1: Resource-based naming
useProject() // ← ambiguous: single project? project list? project mutations?
useProjects() // ← ambiguous: is this different from useProject()?
useProjectData() // ← what data? same as useProject?
// Pattern 2: Operation-based naming
useProject(id) // ← single resource by ID
useProjectList() // ← collection of resources
useProjectMutations() // ← create, update, delete operations
useProjectSearch() // ← search/filter within the collection
Operation-based naming eliminates ambiguity. When there are four project-related composables with different purposes, their names reflect their purpose rather than their subject:
// src/features/projects/composables/useProject.ts
// Single project — fetch by ID, reactive state, no mutations
export function useProject(id: MaybeRef<number>) {
const project = ref<Project | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const projectId = isRef(id) ? id : ref(id)
watch(projectId, async (newId) => {
loading.value = true
error.value = null
try {
project.value = await ProjectApi.find(newId)
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}, { immediate: true })
return { project: readonly(project), loading: readonly(loading), error: readonly(error) }
}
// src/features/projects/composables/useProjectList.ts
// Collection — paginated, filterable, no single-project detail
export function useProjectList(initialFilters?: Partial<ProjectFilters>) {
const projects = ref<Project[]>([])
const filters = reactive<ProjectFilters>({ status: 'active', ...initialFilters })
const pagination = ref<Pagination | null>(null)
const loading = ref(false)
async function fetch(page = 1) {
loading.value = true
const response = await ProjectApi.list({ ...filters, page })
projects.value = response.data
pagination.value = response.meta
loading.value = false
}
watch(filters, () => fetch(1), { deep: true })
onMounted(() => fetch())
return {
projects: readonly(projects),
filters,
pagination: readonly(pagination),
loading: readonly(loading),
fetch,
}
}
The naming discipline also makes the shared composables obviously shared: useAuth, useToast, usePagination — each names what it does, not what feature it relates to.
Pinia Store Structure
Pinia stores should mirror the feature structure:
// src/features/projects/stores/projectStore.ts
import { defineStore } from 'pinia'
import { ProjectApi } from '../api/projectApi'
import type { Project } from '../types/project'
export const useProjectStore = defineStore('projects', {
state: (): { items: Project[]; selected: Project | null; loading: boolean } => ({
items: [],
selected: null,
loading: false,
}),
getters: {
activeProjects: (state) => state.items.filter(p => p.status === 'active'),
projectById: (state) => (id: number) =>
state.items.find(p => p.id === id) ?? null,
},
actions: {
async fetchAll(): Promise<void> {
this.loading = true
try {
this.items = await ProjectApi.list()
} finally {
this.loading = false
}
},
async create(data: CreateProjectDto): Promise<Project> {
const project = await ProjectApi.create(data)
this.items.push(project)
return project
},
async update(id: number, data: UpdateProjectDto): Promise<void> {
const project = await ProjectApi.update(id, data)
const index = this.items.findIndex(p => p.id === id)
if (index !== -1) this.items[index] = project
},
async remove(id: number): Promise<void> {
await ProjectApi.delete(id)
this.items = this.items.filter(p => p.id !== id)
},
},
})
The store owns the canonical state. Composables access the store when they need shared state across components. Composables own their local state when the state is component-specific.
When to use the Pinia store:
→ State shared between multiple components that are not parent/child
→ State that needs to persist across navigation (back button)
→ State that multiple independent composables read or write
When to use composable-local state:
→ State used by only one component or a tightly coupled parent/child pair
→ State that resets when the component unmounts
→ Derived state computed from store state (use computed in the composable)
The mistake that creates Pinia sprawl: putting everything in the store because “it might be needed somewhere else later.” Local composable state that gets promoted to the store when the need arises is correct. Global store state that’s only ever used in one place is overhead.
Where to Put Shared Types
Types have the highest cross-cutting concern of any category. The Project type is needed in the projects feature, in any composable that receives a project, in any component that renders project data, and in any API utility that transforms project responses.
// src/features/projects/types/project.ts
// Feature-owned types — the source of truth for project-related shapes
export interface Project {
id: number
name: string
status: ProjectStatus
ownerId: number
tenantId: number
createdAt: string
updatedAt: string
}
export type ProjectStatus = 'active' | 'archived' | 'completed'
export interface CreateProjectDto {
name: string
status?: ProjectStatus
}
export interface UpdateProjectDto {
name?: string
status?: ProjectStatus
}
// src/shared/types/api.ts
// Shared types used across multiple features
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
export interface ApiError {
message: string
errors?: Record<string, string[]>
}
The rule: feature types live in the feature’s types/ folder. Types that are used by two or more features live in shared/types/. Generic utility types (PaginatedResponse<T>, ApiError) live in shared/types/ from the start, because they’re structurally cross-cutting.
Avoid the pattern of a single top-level types/index.ts that exports every type in the application. It grows to 500 lines, imports become circular when features import from it, and it becomes impossible to tree-shake.
The pages/ vs views/ Naming Debate
Inertia.js users name these pages/. Vue Router users traditionally name them views/. Both are correct. The important distinction is what these files are allowed to do:
// A page/view file should:
// → Import from features
// → Compose feature components together
// → Provide page-level layout
// → Receive route params and pass them to composables
// A page/view file should NOT:
// → Contain business logic (that belongs in composables)
// → Contain direct API calls (that belongs in composables or stores)
// → Be so large that it's not readable at a glance
<!-- pages/projects/Show.vue — a page component done right -->
<script setup lang="ts">
import { useProject } from '@/features/projects'
import { ProjectCard } from '@/features/projects'
import { TaskList } from '@/features/tasks'
const props = defineProps<{ projectId: number }>()
const { project, loading } = useProject(props.projectId)
</script>
<template>
<AppLayout>
<div v-if="loading">
<Spinner />
</div>
<template v-else-if="project">
<ProjectCard :project="project" />
<TaskList :project-id="project.id" />
</template>
</AppLayout>
</template>
The page component is thin. It receives a prop, calls a composable, renders components. The business logic is in the composable. The UI is in the feature components. The page is the composition layer.
The Files That Don’t Have an Obvious Home
Every project has files that don’t fit neatly into the feature/shared structure. The guidance for common cases:
API clients and HTTP utilities:
→ src/shared/api/http.ts ← the axios/fetch wrapper
→ src/features/projects/api/projectApi.ts ← feature-specific endpoints
Constants and configuration:
→ src/shared/constants/ ← app-wide constants
→ src/features/*/constants/ ← feature-specific constants
Route definitions:
→ src/router/index.ts ← all routes in one file for small apps
→ src/router/routes/projects.ts ← per-feature route files for larger apps
→ Imported and composed in src/router/index.ts
Utilities and helpers:
→ src/shared/utils/ ← pure functions used across features
→ formatCurrency.ts, parseDate.ts, debounce.ts
→ Feature-specific utilities live in the feature folder
Testing:
→ Co-located with the source file when using Vitest
src/features/projects/composables/useProject.test.ts
(preferred — keeps tests next to what they test)
→ In a separate __tests__ directory if co-location isn't team preference
The Migration Path From Type-Based to Feature-Based
If you’re on an existing type-based structure with 80 components and want to migrate without breaking everything:
Step 1: Create src/features/ and src/shared/ alongside existing folders
Don't touch existing files yet
Step 2: Identify your top 3 features by file count
Projects, tasks, billing — wherever the most files cluster
Step 3: Create the feature folders for those 3 features
Move files one feature at a time, update imports
Step 4: Create the feature index.ts files
Update all imports outside the feature to use the index
Step 5: Move remaining shared files to src/shared/
Everything used by 2+ features
Step 6: Delete the old type-based folders when empty
components/, composables/, stores/ can go once everything moved
Move one feature per sprint. Don’t try to restructure everything at once. Each moved feature is immediately testable and mergeable. The migration doesn’t require a feature freeze.
The Decision Your Team Regrets the Least
Teams that start with type-based structure and migrate to feature-based structure at 50–80 components consistently report the same experience: the migration takes 2–3 sprints and immediately makes the codebase easier to navigate. Teams that start with feature-based structure from the beginning report that the initial overhead (creating more directories) is invisible six months later.
The teams that regret the feature-based structure are the ones that didn’t enforce the shared/ discipline — everything ended up in shared/ and they recreated the original problem under a different name. The discipline: something goes in shared/ when two features use it, not when you’re unsure where to put it. If you’re unsure, it goes in the feature folder it was built for. You can always promote it to shared/ later. Premature generalization is the structural equivalent of premature optimization.
The specific thing that makes the difference at 100 components is the feature index.ts. Not barrel files in general — the feature public API that defines what’s internal and what’s exported. When a developer is looking for everything related to billing, src/features/billing/ is the answer. When a developer is building a new component that needs to show a project card, import { ProjectCard } from '@/features/projects' is the answer.
That navigability, six months after the initial decisions, in a codebase touched by people who didn’t write the original code, is what the structure is for.
