How to Efficiently Structure a Medium Scale Vue 3 Project
· Jerwin Arnado · 14 min read ·
Small Vue apps don’t need a structure conversation. You’ve got a dozen components, a couple
of views, one store, and a flat src/ is genuinely fine — inventing folders would be
ceremony. Huge apps have their own answers (module federation, a monorepo, separate teams
owning separate deploys). The hard case is the one most of us actually ship: the medium
app. Thirty views, a real Pinia layer, auth, a few distinct feature areas, one or two other
devs. Big enough that the flat folder starts to hurt, small enough that reaching for
micro-frontends would be absurd. That awkward middle is where a good layout pays for itself
every single week — and where a bad one quietly taxes you until a refactor you can’t afford.
Here’s how I structure it, and the one idea it all rests on: organize by feature, not by file type.
The default that rots
Almost every Vue tutorial, and the CLI scaffold, sets you up like this:
src/
components/ # every component in the app, flat
views/ # every page in the app, flat
stores/ # every Pinia store
composables/ # every composable
api/ # every API call
Folders named after what a file is. It’s tidy at ten files. At a hundred it’s a nightmare,
because a single feature — say billing — is now smeared across five folders. To touch
billing you open components/InvoiceRow.vue, views/BillingPage.vue, stores/billing.js,
composables/useInvoices.js, and api/billing.js, hunting each out of a long alphabetical
list it shares with forty unrelated files. Nothing tells you these five belong together. And
when you delete the billing feature, you go spelunking through five folders hoping you got
every piece.
The problem is that you almost never work on “all the components.” You work on a feature. So the folders should be shaped like features.
Organize by module, like the backend already is
If you’ve used my Laravel Module Maker — or any modular Laravel setup — this will feel familiar, because it’s the same instinct. On the backend, a module is a self-contained folder that owns its own routes, controllers, models, and migrations. You add a feature by adding a folder; you remove one by deleting a folder. The codebase reads like the product looks.
Do exactly that on the frontend. Each feature is a module — a folder that owns its views, components, store, API calls, composables, and its own routes:
src/
main.js
App.vue
router/
index.js # aggregates every module's routes (see below)
lib/
http.js # the ONE configured Axios client
shared/ # genuinely cross-module, reused everywhere
components/ # the UI kit: BaseButton, BaseModal, DataTable
composables/ # useToast, usePagination, useClipboard
layouts/ # AppLayout, AuthLayout
modules/
auth/
routes.js
store.js
api.js
views/ # LoginView, RegisterView
components/
billing/
routes.js
store.js
api.js
composables/ # useInvoiceFilters — logic that's ONLY billing's
views/
components/
projects/
routes.js
store.js
api.js
views/
components/
Now billing is one folder. Onboarding a dev is “here’s the projects module, it’s shaped
like all the others.” Deleting a feature is rm -rf modules/billing plus removing nothing
else, because a well-drawn module doesn’t leak. The mental model is the whole win: a
feature is a place, not a scatter.
The rest of this post is the four pieces that make a module self-contained — routes, store, HTTP, composables — plus the two decisions people always ask about.
Split the router per module
This is the piece people most often get wrong, because the docs show you one big routes
array and never revisit it. In a medium app that array becomes a 300-line merge-conflict
magnet that every feature branch touches. Split it.
Each module exports its own routes from routes.js, owning its slice of the URL space:
// src/modules/billing/routes.js
export default [
{
path: '/billing',
component: () => import('./views/BillingLayout.vue'),
children: [
{ path: '', name: 'billing.index', component: () => import('./views/InvoiceList.vue') },
{ path: 'invoices/:id', name: 'billing.invoice', component: () => import('./views/InvoiceDetail.vue') },
],
},
]
Two things worth noting: the () => import() makes each view its own lazy chunk (Vite
code-splits it, so the billing code only downloads when someone visits billing), and the
route names are namespaced — billing.index, billing.invoice — so names never collide
across modules and you can router.push({ name: 'billing.invoice' }) from anywhere without
guessing.
The root router then auto-discovers every module’s routes. Vite’s import.meta.glob does
this in one line — no central list to maintain, no import to forget when you add a module:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// Eagerly pull the default export (the routes array) from every module
const modules = import.meta.glob('../modules/*/routes.js', { eager: true })
const routes = [
{ path: '/', redirect: { name: 'projects.index' } },
...Object.values(modules).flatMap((m) => m.default),
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
Add a new module folder with a routes.js, and its pages are live — you never edit the
router again. That’s the same “drop in a folder, it wires itself” ergonomics Module Maker
gives you on the backend. Global concerns that aren’t a module — a beforeEach auth guard,
a scroll-to-top behavior — stay here in the root router, because they’re cross-cutting by
nature.
One store per module, colocated
Pinia is already modular — every defineStore has its own id and its own file — so the only
decision is where the file lives. Put it inside the module, not in a global stores/:
// src/modules/billing/store.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchInvoices } from './api'
export const useBillingStore = defineStore('billing', () => {
const invoices = ref([])
const loading = ref(false)
async function loadInvoices() {
loading.value = true
try {
invoices.value = await fetchInvoices()
} finally {
loading.value = false
}
}
return { invoices, loading, loadInvoices }
})
The store imports its API layer from right next door (./api), the view imports the store
from right next door, and none of it reaches across module boundaries. When a module needs
another module’s data, it goes through that module’s store — the public surface — never
into its internals. That one rule is what keeps rm -rf modules/billing from breaking three
other features.
One Axios client, per-module API files
Don’t let Axios calls sprawl. Configure exactly one client in lib/http.js, hang your
cross-cutting concerns off its interceptors — base URL, auth token, global 401 handling — and
never call bare axios anywhere else:
// src/lib/http.js
import axios from 'axios'
import { useAuthStore } from '@/modules/auth/store'
export const http = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: { Accept: 'application/json' },
})
http.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
return config
})
http.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) useAuthStore().logout()
return Promise.reject(error)
},
)
Then each module gets a thin api.js that speaks only its own endpoints, importing that
shared client:
// src/modules/billing/api.js
import { http } from '@/lib/http'
export const fetchInvoices = () => http.get('/invoices').then((r) => r.data)
export const fetchInvoice = (id) => http.get(`/invoices/${id}`).then((r) => r.data)
export const payInvoice = (id) => http.post(`/invoices/${id}/pay`).then((r) => r.data)
The payoff: your components and stores never touch URLs, headers, or error handling. If the
API base moves, or auth changes from bearer tokens to cookies, you edit one file and the
whole app follows. This is the frontend version of a repository layer — the rest of the code
asks for fetchInvoices(), not “do an HTTP GET.”
Composables: keep them near what they serve
Composables are just reusable reactive logic, and the only real question is scope. Apply the same by-feature rule:
- A composable used by one module lives in that module —
modules/billing/composables/useInvoiceFilters.js. - A composable used everywhere — a toast queue, pagination, clipboard, a debounced
window-size — lives in
shared/composables/.
// src/modules/billing/composables/useInvoiceFilters.js
import { computed, ref } from 'vue'
export function useInvoiceFilters(invoices) {
const query = ref('')
const results = computed(() =>
invoices.value.filter((i) => i.number.includes(query.value)),
)
return { query, results }
}
The discipline: a composable starts local. You only promote it to shared/ the day a
second module genuinely needs it — not on the speculation that one might. Premature
sharing is how shared/ turns into the junk drawer the whole app depends on and nobody dares
touch.
Wire the aliases in Vite so imports stay flat
One small setup step keeps all of the above from turning into ../../../lib/http spaghetti.
Give src an @ alias:
// vite.config.js
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)),
},
},
})
Now every import reads the same from anywhere — @/lib/http, @/modules/auth/store,
@/shared/components/BaseButton — and moving a file doesn’t break a chain of relative dots.
(Point your editor at the same alias via jsconfig.json so autocomplete and go-to-definition
follow it too.)
Vuetify or Tailwind?
The question I always get. My answer for this class of app is Tailwind, with one honest exception.
Reach for Tailwind when the app has a design — a look someone cares about, a brand, a public face. You get full control, no fighting a component library’s opinions, and utilities that tree-shake down to only what you used. Pair it with a headless primitive library — Radix Vue or Headless UI — for the genuinely hard interactive bits (accessible menus, dialogs, comboboxes) so you’re not reimplementing focus traps, and build the visible layer yourself. That’s the combination behind most bespoke Vue front-ends worth looking at, and it’s the stack this very site is built on.
Reach for Vuetify when it’s an internal tool — an admin panel, a CRUD dashboard, a back-office — where shipping speed beats a custom look and nobody’s grading the pixels. A batteries-included Material component set means you’re not building a date picker from scratch on day one. The cost is real, though: a heavier runtime, a strong visual opinion that’s work to override, and the “every Vuetify app looks like every other Vuetify app” sameness. That’s a fine trade for a tool your team uses and a bad one for a product your customers see.
Decisive version: Tailwind + headless primitives for anything with a face; Vuetify for internal tools where speed wins. Don’t split the difference and run both — pick one styling authority and let it own the look, or you’ll spend your life reconciling two design systems.
Why this holds up
Every choice here points the same direction — make a feature a self-contained place — and the compounding returns show up exactly where medium projects usually rot:
- Onboarding is a template, not a tour. “Every module has routes, a store, an api, and views” is the entire orientation. A new dev is productive in one folder.
- Deleting a feature is deleting a folder. No archaeology across five type-folders hoping you found every orphan. Clean removal is a feature of the layout, not a heroic effort.
- Merge conflicts shrink. Two devs on two features touch two different module folders and never fight over one 300-line router or one god-store.
- It mirrors the backend. When the Vue module map lines up with the Laravel module map, the whole system reads as one idea. Front and back speak the same shape, and moving between them stops requiring a mental translation.
None of this is exotic — it’s Vite, Pinia, Vue Router, and Axios exactly as they ship. The only real decision is the folder you drop them in, and the rule that decides it is the one I’d tattoo on a junior: group by what a thing is for, not by what a thing is. Get that right early, while it’s cheap, and the medium app that usually collapses under its own success just… keeps being easy to work in.