Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 060 / 100

Large Vue app architecture: a practical Vue & Nuxt guide

Structure for growing teams.

Expona Admin6 min read

Introduction

By day 60 of your Vue.js and Nuxt journey, you've likely built features that work—but as your team grows, you're feeling the pain of entangled components and unclear data flow. A large Vue app architecture: a practical Vue & Nuxt guide means designing for teams, not just you. This article walks through proven structural patterns that let multiple developers work in parallel without stepping on each other's toes.

Feature Folders — Components, Composables and Stores per Feature

The foundation of a scalable Vue app is the feature folder pattern. Instead of scattering all components into a global components/ directory and all composables into a shared composables/ folder, organize code by feature. Each feature owns its own components, composables, stores, and utilities.

Consider an e-commerce app with an order management feature. Your folder structure might look like:

typescript
src/features/orders/
├── components/
│   ├── OrderList.vue
│   ├── OrderDetail.vue
│   └── OrderStatusBadge.vue
├── composables/
│   ├── useOrderFilters.ts
│   └── useOrderForm.ts
├── stores/
│   └── orderStore.ts
├── types/
│   └── order.ts
└── index.ts

Each feature is self-contained. When the orders team needs to ship a change, they work within their folder. The index.ts acts as the feature's public API, exporting only what other features should use:

typescript
// src/features/orders/index.ts
export { useOrderFilters } from './composables/useOrderFilters'
export { useOrderForm } from './composables/useOrderForm'
export { orderStore } from './stores/orderStore'
export type { Order, OrderStatus } from './types/order'

This pattern scales because new team members know exactly where order-related code lives. It also makes testing simpler—you test a feature in isolation. When you delete a feature, you delete one folder.

Shared UI — A Design System Package

As you add features, you'll find yourself recreating buttons, modals, input fields, and tables. Extract these into a shared design system package. In a monorepo setup, this is typically a separate workspace package. For a single-app structure, it's a dedicated folder at the root.

A design system package might look like:

typescript
packages/ui/
├── components/
│   ├── Button.vue
│   ├── Modal.vue
│   ├── Input.vue
│   └── Table.vue
├── types/
│   └── component-props.ts
├── styles/
│   └── theme.css
└── index.ts

In a Nuxt app, import these components globally or as needed:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  components: [
    { path: '~/components', pathPrefix: false },
    { path: '~/node_modules/@company/ui/components', pathPrefix: false }
  ]
})

The critical rule: a design system package has no business logic. It doesn't know about orders, users, or payments. It's pure presentation and state management for UI interactions. This keeps it reusable across projects. When you need a new button variant, the whole team can use it immediately.

Boundaries — Features Talk Through Stores or Events

Without clear boundaries, features become a spaghetti mess. The orders feature shouldn't directly import and call functions from the invoicing feature. Instead, establish communication contracts.

Use Pinia stores as the primary contract. When the orders feature completes an order, it publishes that event via a dedicated store:

typescript
// src/features/orders/stores/orderStore.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const orderStore = defineStore('orders', () => {
  const orders = ref([])
  const lastCompletedOrder = ref(null)

  const completeOrder = async (orderId: string) => {
    const order = orders.value.find(o => o.id === orderId)
    if (!order) return
    
    order.status = 'completed'
    lastCompletedOrder.value = order
    // The store is now the single source of truth
  }

  return { orders, lastCompletedOrder, completeOrder }
})

The invoicing feature subscribes to this event by watching the store:

typescript
// src/features/invoicing/composables/useOrderCompletion.ts
import { watch } from 'vue'
import { orderStore } from '@/features/orders'
import { invoicingStore } from '../stores/invoicingStore'

export const useOrderCompletion = () => {
  const orders = orderStore()
  
  watch(
    () => orders.lastCompletedOrder,
    (order) => {
      if (order) {
        invoicingStore().createInvoice(order)
      }
    }
  )
}

// Used in any invoicing component
export default {
  setup() {
    useOrderCompletion()
    return {}
  }
}

This approach keeps features decoupled. The orders feature doesn't know the invoicing feature exists. If you remove invoicing, orders still works. This is the difference between a maintainable app and a legacy codebase.

Monorepo — Share Packages Across Apps

As you grow, you might build multiple apps: a customer portal, an admin dashboard, a mobile app with React Native. A monorepo lets you share the design system, type definitions, and core business logic across all of them.

Tools like Nx, Turborepo, or pnpm workspaces make this practical. A typical structure:

typescript
monorepo/
├── apps/
│   ├── customer-portal/
│   ├── admin-dashboard/
│   └── api-server/
├── packages/
│   ├── ui/
│   ├── types/
│   ├── order-logic/
│   └── api-client/
└── pnpm-workspace.yaml

The packages/types package is used by all apps, ensuring a single source of truth for your data models:

typescript
// packages/types/src/order.ts
export interface Order {
  id: string
  customerId: string
  total: number
  status: 'pending' | 'completed' | 'cancelled'
  createdAt: Date
}

The packages/api-client package handles communication with your backend and is used by both web and mobile apps:

typescript
// packages/api-client/src/orders.ts
import { Order } from '@company/types'

export class OrdersClient {
  async getOrders(): Promise {
    const response = await fetch('/api/orders')
    return response.json()
  }
}

// Used in both apps
import { OrdersClient } from '@company/api-client'

The payoff is consistency and reduced duplication. When you fix a bug in the API client, all apps get the fix. New developers see immediately where shared code lives.

Common Mistakes

Over-engineering from day one

If you're building an MVP with one developer, a feature folder structure is premature. Start simple. Refactor as the team grows. You'll know when you need this structure—it's when two people are fighting over code organization.

Leaky feature boundaries

Features that reach across boundaries create hidden dependencies. If the orders feature directly imports components from invoicing, you've lost the boundary. Stick to the rule: stores and events only.

Design system with business logic

Adding validation logic or API calls to a UI component package defeats its purpose. A UserSelect.vue component should be dumb—it accepts a list of users and emits a selection. Business logic lives in features.

Monorepo without clear ownership

A monorepo without team structure leads to chaos. Assign owners to packages. Document which apps use which packages. Without this clarity, people add dependencies carelessly.

Key Takeaways

  • Organize code into feature folders, not file type folders. Each feature owns its components, composables, stores, and types.
  • Extract shared UI into a dedicated design system package with no business logic.
  • Establish clear boundaries between features. They communicate through Pinia stores or events, not direct imports.
  • For multiple apps, use a monorepo to share types, API clients, and UI packages. This ensures consistency and reduces duplication.
  • Start simple and refactor as your team grows. Premature architecture adds overhead without benefit.
  • Document ownership. Make it clear which team owns which feature or package.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles