DevCerts logo DevCerts

Vue 3 in Production: Composition API, Pinia, and Project Structure Without Spaghetti

Vue 3 does not become maintainable just because a team uses the Composition API and Pinia. Production structure depends on explicit boundaries between UI, reusable behavior, server access, and shared state.

Vue
Vue 3 in Production: Composition API, Pinia, and Project Structure Without Spaghetti

Vue 3 gives teams better primitives for building large applications, but it does not automatically produce a clean architecture. The Composition API can organize logic around behavior instead of component options. Pinia can make shared state explicit. A typed API layer can keep HTTP details out of views. Used without discipline, the same tools create a different kind of spaghetti: composables that do everything, stores that become service containers, and components that still know too much.

The central production rule is simple: separate lifecycle-bound UI behavior, reusable client-side behavior, application state, and server communication. When those boundaries are clear, Vue 3 projects stay easier to test, change, and scale across teams.

What Usually Goes Wrong

Many Vue codebases start with good intentions. A team adopts the Composition API, creates a few composables, adds Pinia, and moves fast. The structure works until several features share the same flows: authentication, permissions, API retries, optimistic updates, filters, tables, forms, and cache invalidation.

The common failure mode is not lack of folders. It is unclear ownership.

A component calls HTTP directly. A composable imports a store, calls an endpoint, mutates global state, and shows a notification. A store manages server data, UI flags, routing decisions, and form state. After a few months, nobody can tell where a bug should be fixed.

A clean Vue 3 project is not defined by how many folders it has. It is defined by whether each piece of logic has one clear reason to change.

Old Pattern vs Production Pattern

The old Vue 2 mental model often pushed teams toward component-heavy code. Options API sections were tidy inside one component, but logic reuse was awkward. Mixins helped reuse, but they also made dependencies implicit.

Vue 3 improves this with composables, but composables are not a replacement for every architectural layer.

Area

Shortcut pattern

Production-oriented pattern

Runtime behavior

Main risk

API calls

fetch() inside components

Typed API functions in an API layer

Same network cost, better isolation

Hidden coupling if skipped

Reusable logic

Large composables with side effects

Small composables with explicit inputs and outputs

Predictable lifecycle when scoped

Composables becoming services

Shared state

One large global store

Feature stores with narrow ownership

Lower accidental reactivity spread

Too many cross-store dependencies

View state

Stored globally by default

Kept local unless shared across routes or features

Lower memory retention

Duplicated state if overdone

Business rules

Inside templates or watchers

Extracted into functions, stores, or domain modules

Easier unit testing

Rules diverge across components

Error handling

Per-component ad hoc logic

API/client policy plus feature-level handling

More consistent UX

Over-centralized generic errors

The goal is not to create a layered enterprise architecture for every screen. The goal is to make common changes cheap: replacing an endpoint, adjusting a store action, reusing a table filter, or testing a composable without mounting a full page.

A Practical Project Structure

A production Vue 3 structure should make the following question easy to answer: where does this logic belong?

One workable structure is feature-first with a small shared core:

src/
  app/
    router/
    plugins/
    config/
  shared/
    api/
      httpClient.ts
      errors.ts
    composables/
      useDebouncedRef.ts
      useAsyncState.ts
    ui/
      BaseButton.vue
      BaseModal.vue
    utils/
      dates.ts
      guards.ts
  features/
    users/
      api/
        userApi.ts
      components/
        UserTable.vue
        UserFilters.vue
      composables/
        useUserFilters.ts
      stores/
        userStore.ts
      types.ts
      pages/
        UsersPage.vue
    billing/
      api/
      components/
      composables/
      stores/
      types.ts
  main.ts

This structure avoids two extremes. It does not put everything into global folders like components, stores, and services, where unrelated code accumulates. It also avoids fragmenting every small feature into too many abstractions before the team understands the shape of the product.

A useful default is:

  • features/* owns product-specific behavior.

  • shared/* contains code that is genuinely reusable across features.

  • app/* contains application setup and integration code.

  • API modules are grouped near the feature that consumes them.

  • Stores are not a dumping ground for all feature logic.

Keep the API Layer Boring

The API layer should be boring by design. It should describe server communication, normalize transport errors, and return typed data. It should not know about components, route names, modals, or notifications.

// shared/api/httpClient.ts
export async function requestJson<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const response = await fetch(`/api${path}`, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  });

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  return response.json() as Promise<T>;
}
// features/users/api/userApi.ts
import { requestJson } from '@/shared/api/httpClient';
import type { User, UserFilters } from '../types';

export function fetchUsers(filters: UserFilters): Promise<User[]> {
  const params = new URLSearchParams();

  if (filters.query) params.set('query', filters.query);
  if (filters.role) params.set('role', filters.role);

  return requestJson<User[]>(`/users?${params.toString()}`);
}

This separation pays off when authentication headers, retry policy, error mapping, or API base paths change. The application does not need every component to understand transport behavior.

Use Composables for Reusable Behavior, Not Hidden Architecture

A composable should usually answer one behavioral question: how do we debounce a value, track async state, manage filters, subscribe to browser events, or coordinate a form?

It should not silently mutate unrelated global stores unless that is explicit in its purpose. The more hidden side effects a composable has, the harder it becomes to reuse safely.

// features/users/composables/useUserFilters.ts
import { computed, ref } from 'vue';
import type { UserFilters } from '../types';
 
export function useUserFilters() {
  const query = ref('');
  const role = ref<string | null>(null);
 
  const filters = computed<UserFilters>(() => ({
    query: query.value.trim(),
    role: role.value,
  }));
 
  function resetFilters() {
    query.value = '';
    role.value = null;
  }
 
  return {
    query,
    role,
    filters,
    resetFilters,
  };
}

This composable is small, testable, and reusable. It does not fetch users. It does not update the router. It does not show notifications. Those concerns may exist elsewhere, but they do not belong inside filter state unless the feature explicitly requires that coupling.

Use Pinia for Shared Application State

Pinia is often overused because it is convenient. A store should hold state that needs to survive component boundaries, coordinate multiple components, or represent feature-level state. It should not be the default place for every input value, loading flag, and temporary modal state.

A useful Pinia store has clear ownership and narrow actions:

// features/users/stores/userStore.ts
import { defineStore } from 'pinia';
import { fetchUsers } from '../api/userApi';
import type { User, UserFilters } from '../types';

type State = {
  items: User[];
  loading: boolean;
  error: string | null;
};

export const useUserStore = defineStore('users', {
  state: (): State => ({
    items: [],
    loading: false,
    error: null,
  }),

  actions: {
    async loadUsers(filters: UserFilters) {
      this.loading = true;
      this.error = null;

      try {
        this.items = await fetchUsers(filters);
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Unknown error';
      } finally {
        this.loading = false;
      }
    },
  },
});

This store coordinates feature state. It depends on the API layer, but it does not know how the page renders a table. It does not own every UI concern. Components can still manage local state where local state is enough.

Components Should Compose, Not Orchestrate Everything

A page component should connect feature pieces without becoming the only place where behavior exists. It can use a composable for filters, a store for shared user state, and child components for display.

<script setup lang="ts">
import { watch } from 'vue';
import { storeToRefs } from 'pinia';
import UserFilters from '../components/UserFilters.vue';
import UserTable from '../components/UserTable.vue';
import { useUserFilters } from '../composables/useUserFilters';
import { useUserStore } from '../stores/userStore';

const userStore = useUserStore();
const { items, loading, error } = storeToRefs(userStore);
const { query, role, filters, resetFilters } = useUserFilters();

watch(
  filters,
  (nextFilters) => {
    userStore.loadUsers(nextFilters);
  },
  { immediate: true }
);
</script>

<template>
  <UserFilters
    v-model:query="query"
    v-model:role="role"
    @reset="resetFilters"
  />

  <p v-if="error">{{ error }}</p>

  <UserTable
    :users="items"
    :loading="loading"
  />
</template>

This page is still responsible for coordination, but the coordination is visible. The page does not hide server access inside a deeply nested component. It does not make the filter composable responsible for global state. It keeps the feature readable.

Testing Becomes Easier When Boundaries Are Explicit

Good structure reduces the cost of testing because each layer can be tested at the right level.

For example:

  • Test userApi.ts with mocked HTTP behavior.

  • Test useUserFilters() as plain reactive logic.

  • Test userStore.ts by mocking fetchUsers().

  • Test UsersPage.vue with integration-style component tests only for wiring and rendering.

  • Test reusable shared composables once, not through every consumer.

This does not eliminate integration testing. It prevents integration tests from becoming the only way to validate basic logic.

What to Adopt First

For an existing Vue 3 codebase, avoid a large folder migration as the first step. Start with ownership.

A practical adoption order:

  1. Move direct HTTP calls out of components into feature API modules.

  2. Split oversized composables into smaller behavior-focused functions.

  3. Keep temporary UI state local unless multiple components need it.

  4. Convert large Pinia stores into feature stores with narrower responsibilities.

  5. Move genuinely reusable code to shared, but only after at least two real consumers exist.

  6. Add tests around extracted logic before changing behavior.

The most common mistake is creating abstractions before the duplication is understood. The second most common mistake is waiting too long, until duplication has already turned into architecture by accident.

Production Trade-Offs

A structured Vue 3 project has coordination cost. Developers need naming conventions, import boundaries, and agreement on what belongs in shared. Small teams may not need a full feature-first layout on day one.

But the cost is usually lower than the alternative in growing applications. Without boundaries, every feature change requires reading components, composables, stores, API calls, and side effects as one tangled system. That slows delivery and increases regression risk.

For teams working heavily with Vue in production, a useful next step is validating whether the same architectural discipline is reflected across component design, state management, testing, and delivery practices. The Senior Vue Developer certification is the most directly relevant option to review for that skill set.


Conclusion

Vue 3 gives teams better tools, but production maintainability comes from architectural boundaries, not from syntax. The Composition API should organize reusable behavior. Pinia should own shared feature state. The API layer should isolate server communication. Components should compose those pieces clearly.

The result is not a more complex project for its own sake. It is a codebase where changes have a predictable place to land, tests can target the right layer, and new developers can understand the system without tracing every side effect through the entire application.