Skip to content

← Writing

engineering

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 namespacedbilling.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.