Cambiar tema

Vue 3 + TypeScript: mejores prácticas — guía de arquitectura empresarial 2025

Easton editorial illustration: cache waterfall instrument

El mes pasado el equipo tuvo una reunión de selección tecnológica y los frontend discutieron media hora sobre gestión de estado, estructura de directorios y configuración de TS sin llegar a un acuerdo. La primera vez que configuré Vue 3 + TypeScript, modifiqué el tsconfig.json más de veinte veces y al cambiar de ordenador volvieron a aparecer errores.
Este artículo resume el enfoque que elaboramos después de tropezar con innumerables obstáculos. No me atrevería a decir que es la solución óptima, pero al menos en tres proyectos de tamaño medio a grande no nos ha dado grandes problemas. Si estás arrancando un proyecto nuevo o quieres modernizar uno antiguo, espero que estas experiencias te ayuden a evitar algunos rodeos.


Guía de selección del stack Vue 3 en 2025

Empecemos por la elección del stack. Vue 3 lleva varios años en el mercado y, en 2025, la respuesta está bastante clara.
El stack estándar de nuestro equipo es: Vite + Vue 3 + TypeScript + Pinia + Vue Router 4. Muchos ya lo usan, pero quiero explicar por qué elegimos estas piezas.
Vite no necesita mucha presentación: la experiencia de desarrollo es mucho mejor que Webpack y el hot reload es prácticamente instantáneo. Vue 3.6 sigue en fase alpha, pero los datos que Evan You compartió en Vue.js Nation 2025 son impresionantes: Vapor Mode puede montar 100 000 componentes en 100 milisegundos. Aún no es viable en producción, pero la dirección es la correcta.
Hablemos de Pinia. Cuando vi su diseño de API, mi primera reacción fue: «Así es como debería ser la gestión de estado». Compara:

// Estilo Vuex (verboso)
const store = createStore({
  state: () => ({ count: 0 }),
  mutations: {
    increment(state) { state.count++ }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})
// Estilo Pinia (limpio)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const increment = () => count.value++
  const incrementAsync = () => setTimeout(increment, 1000)
  return { count, increment, incrementAsync }
})
1.5KB
Tamaño de Pinia
Más ligero y simple que Vuex; el desarrollo de Vuex 5 está prácticamente estancado — Pinia es la opción recomendada para proyectos nuevos

Pinia pesa unos 1,5 KB y el desarrollo de Vuex 5 está prácticamente estancado. A menos que tu proyecto esté profundamente acoplado a Vuex y no tengas planes de migración, no tiene sentido usar Vuex en un proyecto nuevo.


Diseño de la estructura de directorios

He visto demasiados proyectos que amontonan todos los componentes en components/ y, a los tres meses, ni el propio autor encuentra los archivos.
En proyectos pequeños cualquier estructura funciona, pero cuando el proyecto crece, la falta de convenciones es un desastre. Probamos varios enfoques y nos quedamos con este:

src/
├── api/                  # Capa de API
│   ├── modules/          # Dividido por módulo de negocio
│   │   ├── user.ts
│   │   └── order.ts
│   └── index.ts
├── assets/               # Recursos estáticos
│   ├── images/
│   └── styles/
├── components/           # Componentes globales reutilizables
│   ├── base/             # Componentes base (Button, Input, etc.)
│   └── business/         # Componentes de negocio reutilizables
├── composables/          # Funciones composables
│   ├── useAuth.ts
│   └── useRequest.ts
├── layouts/              # Componentes de layout
├── router/               # Configuración de rutas
│   ├── modules/          # Módulos de rutas
│   └── index.ts
├── stores/               # Gestión de estado con Pinia
│   ├── modules/
│   └── index.ts
├── types/                # Definiciones de tipos globales
│   ├── api.d.ts
│   └── global.d.ts
├── utils/                # Funciones de utilidad
├── views/                # Componentes de página
│   ├── user/
│   └── order/
├── App.vue
└── main.ts

Puntos clave:
Diferencia entre composables y utils: composables aloja funciones composables con lógica reactiva, como useAuth o useRequest; utils aloja funciones puras, como formatDate o debounce. Mezclar ambos complica el mantenimiento.
División por dominio funcional, no por tipo de archivo: dentro de api, stores y views se subdivide por módulo de negocio. Así, todo lo relacionado con usuarios está en la carpeta user/.
Tipos en directorio separado: las definiciones globales van en types/; los tipos internos de un componente pueden quedarse en el propio archivo. No amontones todos los tipos en una sola carpeta: eso también genera caos.


Mejores prácticas de definición de tipos en TypeScript

Siendo honestos, la «gimnasia de tipos» de TS puede desanimar, pero dominar estos escenarios cubre la mayoría de las necesidades.
Empieza por tsconfig.json; estas opciones son imprescindibles:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

strict: true es obligatorio. Al principio verás muchos errores, pero a largo plazo merece la pena. moduleResolution: "bundler" es una opción relativamente nueva que funciona muy bien con Vite.
Luego, los tipos en componentes Vue. La primera vez que vi defineProps<Props>() me quedé unos segundos sin entender cómo se pasaba el genérico. Es la magia del compilador de Vue:

<script setup lang="ts">
// Definición de tipos de Props
interface Props {
  title: string
  count?: number
  items: string[]
}
const props = defineProps<Props>()
// Props con valores por defecto
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
// Definición de tipos de Emits
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// O la sintaxis más concisa (Vue 3.3+)
const emit2 = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()
</script>

Otro punto donde suele fallar la gente: el reconocimiento de tipos en archivos .vue. Si el IDE dice que no encuentra el módulo, crea env.d.ts o shims-vue.d.ts en src/:

/// <reference types="vite/client" />
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Pinia en la práctica

Después de acostumbrarte a las mutations de Vuex, la primera vez que modificas el state directamente en Pinia parece que estás haciendo algo prohibido. Luego entiendes que las mutations eran un lastre heredado de la arquitectura Flux y Pinia se deshizo de esa carga.
Recomendamos definir stores con estilo Composition API:

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserInfo, login } from '@/api/modules/user'
export const useUserStore = defineStore('user', () => {
  // state
  const token = ref<string>('')
  const userInfo = ref<UserInfo | null>(null)
  // getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name ?? 'Invitado')
  // actions
  const setToken = (newToken: string) => {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }
  const fetchUserInfo = async () => {
    try {
      const res = await getUserInfo()
      userInfo.value = res.data
    } catch (error) {
      console.error('Error al obtener información del usuario', error)
    }
  }
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }
  return {
    token,
    userInfo,
    isLoggedIn,
    userName,
    setToken,
    fetchUserInfo,
    logout
  }
})

Al usarlo, cuidado con un detalle: desestructurar el estado reactivo lo rompe. Usa storeToRefs():

import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Incorrecto: pierde reactividad al desestructurar
const { userName, isLoggedIn } = userStore
// Correcto
const { userName, isLoggedIn } = storeToRefs(userStore)
// Las actions se pueden desestructurar directamente porque son funciones normales
const { logout, fetchUserInfo } = userStore

Para persistencia, recomendamos el plugin pinia-plugin-persistedstate; la configuración es sencilla:

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Habilitar en el store
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true  // o configurar opciones específicas
})

Vue Router 4: rutas con tipos seguros

Los guards de ruta pueden volverse adictivos: siempre quieres meter más lógica en beforeEach. En un proyecto anterior, el beforeEach llegó a casi 200 líneas con validación de permisos, tracking y título de página; solo fue mantenible cuando lo dividimos en varios guards.
Primero, la definición de tipos de rutas:

// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// Extender el tipo meta de rutas
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
  }
}
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/home/index.vue'),
    meta: {
      title: 'Inicio',
      requiresAuth: false
    }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: 'Panel de control',
      requiresAuth: true,
      roles: ['admin', 'editor']
    }
  }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

El guard de autenticación puede escribirse así:

// router/guards/auth.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
export function setupAuthGuard(router: Router) {
  router.beforeEach((to, from, next) => {
    const userStore = useUserStore()
    // Páginas sin login: pasar directamente
    if (!to.meta.requiresAuth) {
      next()
      return
    }
    // Sin sesión: redirigir al login
    if (!userStore.isLoggedIn) {
      next({ path: '/login', query: { redirect: to.fullPath } })
      return
    }
    // Validación de permisos
    const { roles } = to.meta
    if (roles && roles.length > 0) {
      const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
      if (!hasRole) {
        next({ path: '/403' })
        return
      }
    }
    next()
  })
}

Para modularizar rutas, divídelas por dominio de negocio y combínalas en index.ts:

// router/modules/user.ts
export const userRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      {
        path: 'profile',
        name: 'UserProfile',
        component: () => import('@/views/user/profile.vue')
      }
    ]
  }
]

Estándares de código y configuración de ingeniería

Tras el cambio a flat config en ESLint 9, migré la configuración antigua durante toda una tarde. El antiguo .eslintrc quedó obsoleto; la nueva configuración se ve así:

// eslint.config.js
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
export default [
  js.configs.recommended,
  ...vue.configs['flat/recommended'],
  {
    files: ['**/*.{ts,tsx,vue}'],
    languageOptions: {
      parser: vueParser,
      parserOptions: {
        parser: tsParser,
        sourceType: 'module'
      }
    },
    plugins: {
      '@typescript-eslint': typescript
    },
    rules: {
      'vue/multi-word-component-names': 'off',
      '@typescript-eslint/no-unused-vars': 'warn',
      '@typescript-eslint/no-explicit-any': 'warn'
    }
  }
]

Para evitar conflictos entre Prettier y ESLint, usa eslint-config-prettier; desactiva las reglas de ESLint que chocan con Prettier.
unplugin-auto-import es muy útil: importa automáticamente las APIs de Vue, Vue Router y Pinia y te ahorra montones de import:

// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      eslintrc: {
        enabled: true
      }
    })
  ]
})

Configurado así, puedes escribir ref() y computed() directamente sin importarlos a mano.
Para commits, usa husky + lint-staged y ejecuta lint antes de cada commit:

// package.json
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint . --fix",
    "lint-staged": "lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
  }
}

Para cerrar

Al escribir esto, recuerdo la sensación de desconcierto cuando empecé con Vue 3. La Composition API acababa de salir, la comunidad debatía si adoptarla o quedarse en Options API, los tutoriales eran desiguales y los tropiezos eran habituales.
Este enfoque no encaja en todos los proyectos. En uno pequeño puedes simplificar: provide/inject basta para el estado y la estructura de directorios no necesita tanta granularidad. Pero si trabajas en un proyecto empresarial de tamaño medio o grande con un equipo de tres o más personas, esta arquitectura debería ahorrarte bastante trabajo.
No hay bala de plata en la selección tecnológica; lo importante es que el equipo llegue a un acuerdo, defina convenciones y las cumpla. Espero que este artículo te sirva de referencia y te evite algunos rodeos.
¿Cómo monta tu equipo los proyectos Vue 3? ¿Tienes buenas prácticas que compartir? Déjalas en los comentarios.

Flujo completo para montar una arquitectura empresarial Vue 3 + TypeScript

Pasos completos desde la inicialización del proyecto hasta los estándares de código, incluyendo mejores prácticas con Vite, Pinia, Vue Router y ESLint

Estimated time: PT4H

  1. 1

    Step 1: Inicialización del proyecto y selección del stack

    Crear proyecto con Vite:
  2. 2

    Step 2: Diseñar la estructura de directorios

    Estructura dividida por dominio funcional:
  3. 3

    Step 3: Configurar definiciones de tipos en TypeScript

    Configuración clave de tsconfig.json:
  4. 4

    Step 4: Configurar gestión de estado con Pinia

    Recomendado: definir stores con estilo Composition API:
  5. 5

    Step 5: const userInfo = ref<UserInfo

    null>(null);
  6. 6

    Step 6: Configurar rutas con tipos seguros en Vue Router 4

    Extender el tipo meta de rutas:
  7. 7

    Step 7: Configurar ESLint 9 y estándares de código

    ESLint 9 con flat config:

FAQ

¿Cuál es la diferencia entre Pinia y Vuex? ¿Por qué se recomienda Pinia?
Pinia pesa solo 1,5 KB y el desarrollo de Vuex 5 está prácticamente estancado.

Comparación de APIs:

Vuex requiere tres capas: state, mutations y actions:
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})

Pinia con estilo Composition API es más conciso:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})

Ventajas de Pinia:
• Sin mutations (modificación directa del state)
• Mejor soporte de TypeScript
• Menor tamaño
• API más intuitiva

A menos que tu proyecto esté profundamente acoplado a Vuex y no tengas planes de migración, no tiene sentido usar Vuex en un proyecto nuevo.
¿Cómo diseñar la estructura de directorios en un proyecto Vue 3? ¿Cuál es la diferencia entre composables y utils?
Estructura dividida por dominio funcional, no por tipo de archivo:
• src/api/modules/ (por módulo de negocio, p. ej. user.ts, order.ts)
• src/components/base/ y business/ (componentes base y de negocio)
• src/composables/ (funciones composables con lógica reactiva, p. ej. useAuth, useRequest)
• src/utils/ (funciones puras, p. ej. formatDate, debounce)
• src/stores/modules/ (Pinia por módulo de negocio)
• src/views/ (páginas por módulo, p. ej. user/, order/)
• src/types/ (definiciones de tipos globales)

Diferencia entre composables y utils:
• composables: funciones composables con lógica reactiva (useAuth, useRequest)
• utils: funciones puras (formatDate, debounce)
• Mezclarlos complica el mantenimiento

Ventajas de la división por dominio:
• api, stores y views subdivididos por módulo de negocio
• Todo lo relacionado con usuarios queda en la carpeta user/
¿Cómo configurar TypeScript en Vue 3? ¿Cuáles son los puntos clave?
Configuración clave de tsconfig.json:
• Activar strict: true (obligatorio; al principio habrá muchos errores, pero vale la pena a largo plazo)
• moduleResolution: 'bundler' (opción relativamente nueva; funciona muy bien con Vite)
• paths con alias @/*

Tipos en componentes Vue:
• Sintaxis genérica defineProps<Props>():
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• Valores por defecto: withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• Emits: defineEmits<Emits>() o la sintaxis más concisa:
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()

Reconocimiento de tipos en .vue:
Crear env.d.ts o shims-vue.d.ts en src/:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

Si el IDE dice que no encuentra el módulo, suele faltar este archivo.
¿Cómo usar Pinia para gestión de estado? ¿Qué precauciones hay?
Recomendado: definir stores con estilo Composition API:
Usar defineStore('user', () => {
const token = ref<string>('');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const fetchUserInfo = async () => {
try {
const res = await getUserInfo();
userInfo.value = res.data;
} catch (error) {
console.error('Error al obtener información del usuario', error);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})

Precauciones:
• Al desestructurar estado reactivo, usar storeToRefs(): const { userName, isLoggedIn } = storeToRefs(userStore)
• Las actions se pueden desestructurar directamente: const { logout, fetchUserInfo } = userStore

Persistencia:
• Instalar pinia-plugin-persistedstate
• En main.ts: pinia.use(piniaPluginPersistedstate)
• En el store: persist: true o configurar opciones específicas

Tras acostumbrarte a las mutations de Vuex, modificar el state directamente en Pinia puede parecer raro, pero las mutations eran un lastre heredado de Flux que Pinia eliminó.
¿Cómo configurar flat config en ESLint 9? ¿En qué se diferencia de la versión anterior?
Con ESLint 9 y flat config, el antiguo .eslintrc quedó obsoleto.

Nueva configuración:
• Crear eslint.config.js
• Usar js.configs.recommended y vue.configs['flat/recommended']
• Configurar files: ['**/*.{ts,tsx,vue}']
• languageOptions.parser como vue-eslint-parser
• parserOptions.parser como @typescript-eslint/parser
• plugins con @typescript-eslint
• rules específicas ('vue/multi-word-component-names': 'off', '@typescript-eslint/no-unused-vars': 'warn')

Integración con Prettier:
• Usar eslint-config-prettier para resolver conflictos
• Desactiva las reglas de ESLint que chocan con Prettier

Importación automática:
• Instalar unplugin-auto-import
• En vite.config.ts: AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' })
• Tras configurarlo, escribir ref() y computed() sin import manual

Commits con Git:
• Configurar husky + lint-staged
• Definir lint-staged en package.json ("*.{js,ts,vue}": ["eslint --fix", "prettier --write"])
• Ejecutar lint automáticamente antes de cada commit
¿Cómo configurar rutas con tipos seguros y guards de permisos en Vue Router 4?
Extender el tipo meta de rutas:
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}

Configuración de rutas:
• Usar tipo RouteRecordRaw[]
• Definir title, requiresAuth, roles en meta

Guard de permisos:
• Crear setupAuthGuard en router/guards/auth.ts
• Usar router.beforeEach para comprobar to.meta.requiresAuth (páginas sin login pasan directamente)
• Sin sesión: next({ path: '/login', query: { redirect: to.fullPath } })
• Validar roles: const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
• Sin permiso: redirigir a 403

Modularización:
• Dividir por dominio de negocio (router/modules/user.ts)
• Combinar en index.ts

Los guards pueden volverse adictivos: en un proyecto anterior el beforeEach llegó a casi 200 líneas con permisos, tracking y título; solo fue mantenible al dividirlo en varios guards.

9 min de lectura · Publicado el: 24 nov 2025 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog