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

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 }
})
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
Step 1: Inicialización del proyecto y selección del stack
Crear proyecto con Vite: -
2
Step 2: Diseñar la estructura de directorios
Estructura dividida por dominio funcional: -
3
Step 3: Configurar definiciones de tipos en TypeScript
Configuración clave de tsconfig.json: -
4
Step 4: Configurar gestión de estado con Pinia
Recomendado: definir stores con estilo Composition API: -
5
Step 5: const userInfo = ref<UserInfo
null>(null); -
6
Step 6: Configurar rutas con tipos seguros en Vue Router 4
Extender el tipo meta de rutas: -
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?
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?
• 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?
• 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?
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?
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?
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
Framework frontend
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
¿Formularios en React 19 con 30 líneas? Actions los simplifica y sube el rendimiento un 40 %
Análisis profundo de las 6 funciones clave de React 19: Actions, el hook use(), el compilador y más. Con código práctico comparamos cómo simplificar formularios, optimizar rendimiento y usar Server Components. Una semana de experiencia real para que empieces rápido.
Parte 4 de 6
Siguiente
Optimización de rendimiento frontend: guía completa de Core Web Vitals
Guía sistemática para optimizar las tres métricas de Core Web Vitals (LCP/INP/CLS): imágenes, code splitting, lazy loading y más de 10 soluciones prácticas. De 60 a 90+ puntos en Lighthouse en 2 semanas, con checklist completo y errores a evitar.
Parte 6 de 6



Comentarios
Inicia sesión con GitHub para dejar un comentario