Changer le thème

Vue 3 + TypeScript : bonnes pratiques et architecture entreprise en 2025

Easton editorial illustration: cache waterfall instrument

Le mois dernier, l’équipe a tenu une réunion de choix technique : état global, arborescence, config TS — sans consensus. Ma première config Vue 3 + TypeScript ? J’ai retouché tsconfig.json une vingtaine de fois ; sur une autre machine, tout replantait.

Cet article résume ce qu’on a appris après de nombreux essais. Ce n’est pas la solution parfaite, mais trois projets moyens/grands tournent sans gros incident. Nouveau projet ou refonte d’architecture : ces pistes devraient vous faire gagner du temps.


Choix de stack Vue 3 en 2025

Vue 3 est là depuis longtemps ; en 2025, le choix est assez clair.

Notre stack : Vite + Vue 3 + TypeScript + Pinia + Vue Router 4. Beaucoup l’utilisent déjà ; voici pourquoi ces briques.

Vite : HMR quasi instantané, bien meilleur que Webpack au quotidien. Vue 3.6 est encore en alpha, mais Evan You a montré à Vue.js Nation 2025 des chiffres impressionnants — le Vapor Mode peut monter 100 000 composants en ~100 ms. Pas pour la prod aujourd’hui, mais la direction est bonne.

Pinia en priorité. En voyant l’API, ma première réaction : « enfin une gestion d’état comme il faut ». Comparaison :

// Vuex (verbeux)
const store = createStore({
  state: () => ({ count: 0 }),
  mutations: {
    increment(state) { state.count++ }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})
// Pinia (plus direct)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const increment = () => count.value++
  const incrementAsync = () => setTimeout(increment, 1000)
  return { count, increment, incrementAsync }
})
1,5 Ko
Taille de Pinia
Plus léger que Vuex ; Vuex 5 quasi à l’arrêt — Pinia recommandé pour les nouveaux projets

Pinia fait environ 1,5 Ko. Sauf projet fortement lié à Vuex sans plan de migration, les nouveaux projets n’ont plus vraiment intérêt à Vuex.


Structure de répertoires

Trop de projets empilent tout dans components/ — trois mois plus tard, personne ne retrouve rien.

Sur un petit repo, tout va ; à l’échelle, sans convention c’est le chaos. Après plusieurs essais, on est restés sur ceci :

src/
├── api/                  # Couche API
│   ├── modules/          # Par module métier
│   │   ├── user.ts
│   │   └── order.ts
│   └── index.ts
├── assets/               # Assets statiques
│   ├── images/
│   └── styles/
├── components/           # Composants globaux
│   ├── base/             # Button, Input, etc.
│   └── business/         # Composants métier partagés
├── composables/          # Fonctions composables
│   ├── useAuth.ts
│   └── useRequest.ts
├── layouts/              # Layouts de page
├── router/               # Routes
│   ├── modules/
│   └── index.ts
├── stores/               # Pinia
│   ├── modules/
│   └── index.ts
├── types/                # Types globaux
│   ├── api.d.ts
│   └── global.d.ts
├── utils/                # Utilitaires
├── views/                # Pages
│   ├── user/
│   └── order/
├── App.vue
└── main.ts

Points clés :

composables vs utils : composables = logique réactive (useAuth, useRequest) ; utils = fonctions pures (formatDate, debounce). Les mélanger complique la maintenance.

Découpage par domaine : sous-dossiers métier dans api, stores, views — tout ce qui concerne l’utilisateur vit sous user/.

types à part : types globaux dans types/ ; types locaux dans le composant. Ne pas tout jeter dans un seul dossier types/.


Bonnes pratiques TypeScript

Le « sport » des types peut décourager ; quelques patterns couvrent la majorité des cas.

tsconfig.json — à activer :

{
  "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 : beaucoup d’erreurs au début, rentable à long terme. moduleResolution: "bundler" va bien avec Vite.

Typage des composants — defineProps<Props>() peut surprendre au premier coup ; c’est géré par le compilateur Vue :

<script setup lang="ts">
// Props
interface Props {
  title: string
  count?: number
  items: string[]
}
const props = defineProps<Props>()
// Props avec défauts
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
// Emits
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Ou plus concis (Vue 3.3+)
const emit2 = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()
</script>

Fichiers .vue non reconnus par l’IDE : ajouter env.d.ts ou shims-vue.d.ts dans src/ :

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

Pinia en pratique

Après les mutations Vuex, modifier le state directement dans Pinia semble « interdit » — en réalité, les mutations étaient surtout un héritage Flux ; Pinia s’en débarrasse.

Store en style 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 ?? 'Invité')
  // 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('Échec récupération utilisateur', error)
    }
  }
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }
  return {
    token,
    userInfo,
    isLoggedIn,
    userName,
    setToken,
    fetchUserInfo,
    logout
  }
})

Piège classique — la déstructuration casse la réactivité ; utiliser storeToRefs() :

import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Incorrect — perte de réactivité
const { userName, isLoggedIn } = userStore
// Correct
const { userName, isLoggedIn } = storeToRefs(userStore)
// Les actions se déstructurent directement (fonctions normales)
const { logout, fetchUserInfo } = userStore

Persistance : pinia-plugin-persistedstate :

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Dans le store
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true  // ou options détaillées
})

Vue Router 4 : routes typées

Les guards beforeEach s’accumulent vite — un projet avait ~200 lignes (droits, tracking, titre) ; on a découpé en plusieurs guards.

Types pour meta :

// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
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: 'Accueil',
      requiresAuth: false
    }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: 'Tableau de bord',
      requiresAuth: true,
      roles: ['admin', 'editor']
    }
  }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

Guard d’authentification :

// 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()
    if (!to.meta.requiresAuth) {
      next()
      return
    }
    if (!userStore.isLoggedIn) {
      next({ path: '/login', query: { redirect: to.fullPath } })
      return
    }
    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()
  })
}

Modulariser par domaine métier :

// 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')
      }
    ]
  }
]

Qualité de code et outillage

ESLint 9 en flat config : migration d’un après-midi depuis .eslintrc :

// 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'
    }
  }
]

Conflits Prettier/ESLint : eslint-config-prettier.

unplugin-auto-import importe Vue, Vue Router, Pinia automatiquement :

// 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
      }
    })
  ]
})

Ensuite ref(), computed() sans import manuel.

Commits : husky + lint-staged :

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

Pour conclure

Au début de Vue 3, la Composition API et les tutoriels hétérogènes faisaient beaucoup d’erreurs — on connaît.

Cette stack n’est pas universelle. Petit projet : provide/inject, arborescence légère. Projet entreprise, équipe ≥ 3 personnes : cette base a fait ses preuves.

Pas de solution miracle — consensus d’équipe, conventions, exécution. J’espère que cela vous évite quelques détours.

Comment structurez-vous vos projets Vue 3 ? Partagez vos retours en commentaires.

Mettre en place une architecture Vue 3 + TypeScript entreprise

De l'initialisation aux règles de code : Vite, Pinia, Vue Router, ESLint et bonnes pratiques

⏱️ Estimated time: 4 hr

  1. 1

    Step 1: Initialisation et choix de stack

    Créer le projet Vite :
    • npm create vite@latest my-project -- --template vue-ts
    • cd my-project
    • npm install

    Stack :
    • Vite + Vue 3 + TypeScript + Pinia + Vue Router 4
    • npm install pinia vue-router@4

    Atouts :
    • HMR quasi instantané vs Webpack
    • Vapor Mode (Vue 3.6) : ~100 ms pour 100 000 composants — pas encore prod, mais prometteur
  2. 2

    Step 2: Concevoir l'arborescence

    Découpage par domaine :

    • src/api/modules/ (user.ts, order.ts…)
    • src/components/base/ et business/
    • src/composables/ (useAuth, useRequest — logique réactive)
    • src/utils/ (formatDate, debounce — fonctions pures)
    • src/stores/modules/
    • src/views/ (user/, order/…)
    • src/types/ (types globaux)

    • Séparer composables et utils
    • Sous-dossiers métier dans api, stores, views
  3. 3

    Step 3: Configurer TypeScript

    tsconfig.json :
    • strict: true
    • moduleResolution: bundler (avec Vite)
    • alias @/*

    Composants Vue :
    • defineProps<Props>()
    • withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
    • defineEmits<Emits>()

    Fichiers .vue :
    • env.d.ts ou shims-vue.d.ts avec declare module '*.vue'
  4. 4

    Step 4: Configurer Pinia

    Store en Composition API :

    defineStore('user', () => {
    const token = ref<string>('');
    const userInfo = ref<UserInfo | null>(null);
    const isLoggedIn = computed(() => !!token.value);
    const fetchUserInfo = async () => { ... };
    return { token, userInfo, isLoggedIn, fetchUserInfo }
    })

    Usage :
    • Réactivité : storeToRefs(userStore)
    • Actions : déstructuration directe

    Persistance :
    • pinia-plugin-persistedstate
    • pinia.use(piniaPluginPersistedstate)
    • persist: true dans le store
  5. 5

    Step 5: Vue Router 4 typé

    Étendre RouteMeta :
    declare module 'vue-router' {
    interface RouteMeta {
    title?: string;
    requiresAuth?: boolean;
    roles?: string[];
    }
    }

    • routes: RouteRecordRaw[]
    • setupAuthGuard : beforeEach, requiresAuth, roles
    • Modules router/modules/*.ts fusionnés dans index.ts
  6. 6

    Step 6: ESLint 9 et conventions

    Flat config (eslint.config.js) :
    • js.configs.recommended, vue flat/recommended
    • vue-eslint-parser + @typescript-eslint/parser

    • eslint-config-prettier
    • unplugin-auto-import (vue, vue-router, pinia)
    • husky + lint-staged avant commit

FAQ

Pinia vs Vuex : pourquoi Pinia ?
Pinia ~1,5 Ko ; Vuex 5 quasi à l'arrêt.

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

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

Avantages Pinia :
• Pas de mutations
• Meilleur TypeScript
• Plus léger, API plus claire

Sauf projet lié à Vuex sans migration, préférez Pinia.
Comment structurer un projet Vue 3 ? Différence composables / utils ?
Par domaine fonctionnel :
• api/modules/, components/base|business/
• composables/ (logique réactive)
• utils/ (fonctions pures)
• stores/modules/, views/ par métier
• types/ global

composables vs utils :
• composables : useAuth, useRequest
• utils : formatDate, debounce
• Ne pas mélanger — maintenance difficile

api, stores, views : sous-dossiers métier (ex. user/) pour retrouver vite les fichiers.
Comment configurer TypeScript avec Vue 3 ?
tsconfig.json :
• strict: true
• moduleResolution: bundler
• paths @/*

Composants :
• defineProps<Props>()
• withDefaults pour les défauts
• defineEmits<{ update: [value: string]; delete: [id: number] }>()

.vue :
env.d.ts ou shims-vue.d.ts avec declare module '*.vue'.

IDE qui ne trouve pas *.vue : souvent ce fichier manque.
Comment utiliser Pinia ? Pièges à éviter ?
Store Composition API avec defineStore('user', () => { ... }).

• Déstructuration réactive : storeToRefs(userStore)
• Actions : déstructuration directe

Persistance : pinia-plugin-persistedstate, persist: true.

Modifier le state sans mutations peut surprendre après Vuex — c'est voulu par Pinia.
ESLint 9 flat config : comment migrer ?
L'ancien .eslintrc ne s'applique plus.

• eslint.config.js
• js.configs.recommended, vue flat/recommended
• vue-eslint-parser, @typescript-eslint/parser
• eslint-config-prettier
• unplugin-auto-import dans vite.config.ts
• husky + lint-staged dans package.json
Vue Router 4 : routes typées et guards ?
Étendre RouteMeta (title, requiresAuth, roles).

• RouteRecordRaw[]
• setupAuthGuard : requiresAuth, redirect login, rôles → 403
• router/modules/*.ts + fusion index

Éviter un seul beforeEach géant (~200 lignes) : plusieurs guards spécialisés.

6 min de lecture · Publié le: 24 nov. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog