Vue 3 + TypeScript Best Practices: 2025 Enterprise Architecture Guide

Introduction
Last month our team held a tech stack selection meeting—the atmosphere got pretty awkward. Right after the product manager left, a few frontend developers started arguing—one said to use Vuex, another insisted on Pinia, and one guy (who switched from React) stubbornly wanted Redux. How to organize directory structure? Should TypeScript be in strict mode? These questions were debated without conclusion, the meeting room whiteboard filled up and erased repeatedly.
Honestly, the first time I configured Vue 3 + TypeScript, I modified tsconfig.json no less than 20 times. Each time I thought I had it right, switching computers would throw errors. That frustrating feeling—anyone who’s done it knows.
This article is a solution our team summarized after stepping on countless landmines. I wouldn’t call it the optimal solution, but at least it’s run smoothly on three medium-to-large projects without major issues. If you’re starting a new project or want to upgrade an old project’s architecture, hopefully these experiences can help you avoid some detours.
2025 Vue 3 Tech Stack Selection Guide
Let’s first talk about tech stack selection. Vue 3 has been out for years—what to use in 2025 is actually pretty clear now.
Our team’s current standard configuration is: Vite + Vue 3 + TypeScript + Pinia + Vue Router 4. Many people might already be using this combination, but let me explain why these specifically.
Vite doesn’t need much explanation—the development experience is far better than Webpack, hot updates are basically instant. Although Vue 3.6 is still in alpha, the data Evan You revealed in his Vue.js Nation 2025 talk is pretty impressive—Vapor Mode can mount 100K components in 100 milliseconds. Although production can’t use it yet, the direction is right.
Let me focus on Pinia. Honestly, when I saw Pinia’s API design, my first reaction was: “This is what state management should look like.” Compare:
// Vuex approach (verbose)
const store = createStore({
state: () => ({ count: 0 }),
mutations: {
increment(state) { state.count++ }
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => commit('increment'), 1000)
}
}
})
// Pinia approach (clean)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
const incrementAsync = () => setTimeout(increment, 1000)
return { count, increment, incrementAsync }
})Pinia is only about 1.5kb, and Vuex 5 development has basically stalled. Unless your project is already deeply tied to Vuex with no migration plan, there’s really no reason to use Vuex for new projects.
Project Directory Structure Design
I’ve seen too many projects dump all components in the components folder—after three months even they can’t find files.
Directory structure is fine however you do it for small projects, but for large projects, no standards is a disaster. Our team tried several approaches, and this is what we settled on:
src/
├── api/ # API interface layer
│ ├── modules/ # Split by business module
│ │ ├── user.ts
│ │ └── order.ts
│ └── index.ts
├── assets/ # Static resources
│ ├── images/
│ └── styles/
├── components/ # Global common components
│ ├── base/ # Base components (Button, Input, etc.)
│ └── business/ # Business common components
├── composables/ # Composition functions
│ ├── useAuth.ts
│ └── useRequest.ts
├── layouts/ # Page layout components
├── router/ # Router configuration
│ ├── modules/ # Router modules
│ └── index.ts
├── stores/ # Pinia state management
│ ├── modules/
│ └── index.ts
├── types/ # Global type definitions
│ ├── api.d.ts
│ └── global.d.ts
├── utils/ # Utility functions
├── views/ # Page components
│ ├── user/
│ └── order/
├── App.vue
└── main.tsHere are some key points:
Difference between composables and utils: composables contains composition functions with reactive logic, like useAuth, useRequest; utils contains pure utility functions, like formatDate, debounce. Many people mix these together, making later maintenance painful.
Split by functional domain, not file type: api, stores, views all have another layer split by business module. This makes finding files logically clear—user-related stuff is all in the user folder.
Separate types: Global type definitions go in the types directory, component-internal types can be written in component files. Don’t pile all types into one types folder—that’s more chaotic.
TypeScript Type Definition Best Practices
Honestly, TS type gymnastics can be a bit intimidating, but mastering these few scenarios is enough for most needs.
First, tsconfig.json—these configurations must be enabled:
{
"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 is mandatory. Although it’ll throw a bunch of errors initially, it’s definitely worth it long-term. moduleResolution: "bundler" is a relatively new option, works better with Vite.
Then Vue component type definitions. The first time I saw defineProps<Props>() syntax, I was confused for several seconds, not knowing how the generic was passed in. Actually, this is Vue compiler magic:
<script setup lang="ts">
// Props type definition
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
// Props with defaults
const propsWithDefaults = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
// Emits type definition
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Or more concise syntax (Vue 3.3+)
const emit2 = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
</script>Another easy pitfall—.vue file type recognition. If your IDE reports module not found errors, create an env.d.ts or shims-vue.d.ts in the src directory:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}Pinia State Management in Practice
After getting used to Vuex mutations, directly modifying state in Pinia felt wrong at first, like doing something against the rules. Later I realized, the mutations pattern is actually legacy baggage from Flux architecture. Pinia simply shed that burden.
Our team recommends using Composition API style to define Stores:
// 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 ?? 'Guest')
// 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('Failed to fetch user info', error)
}
}
const logout = () => {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
userName,
setToken,
fetchUserInfo,
logout
}
})When using, note one pitfall—destructuring loses reactivity. Use storeToRefs():
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Wrong: destructuring loses reactivity
const { userName, isLoggedIn } = userStore
// Correct
const { userName, isLoggedIn } = storeToRefs(userStore)
// Actions can be directly destructured, they're regular functions
const { logout, fetchUserInfo } = userStoreFor persistent storage, recommend the pinia-plugin-persistedstate plugin—configuration is simple:
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Enable in store
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: true // or configure specific options
})Vue Router 4 Type-Safe Route Configuration
Writing route guards can be addictive—you always want to stuff all kinds of logic into beforeEach. We had a project where beforeEach code reached nearly 200 lines, with various permission checks, tracking, title setting all piled in. Later we split into multiple guards for better maintenance.
First look at route configuration type definitions:
// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// Extend route meta type
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: 'Home',
requiresAuth: false
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: 'Dashboard',
requiresAuth: true,
roles: ['admin', 'editor']
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default routerPermission check guards can be written like this:
// 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()
// Pages not requiring login pass directly
if (!to.meta.requiresAuth) {
next()
return
}
// Redirect to login if not logged in
if (!userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Permission check
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()
})
}For route modularization, I suggest splitting by business domain into multiple files, then merging in 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')
}
]
}
]Code Standards and Engineering Configuration
After ESLint 9 switched to flat config, I spent a whole afternoon migrating old configurations. The old .eslintrc setup was completely obsolete. The new configuration looks like this:
// 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'
}
}
]For the Prettier and ESLint conflict issue, use eslint-config-prettier to solve it—this package disables ESLint rules that conflict with Prettier.
unplugin-auto-import is a godsend—it can auto-import Vue, Vue Router, Pinia APIs, saving a bunch of import statements:
// 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
}
})
]
})After configuration, you can write ref(), computed() directly in code without manual imports.
For Git commit standards, use husky + lint-staged to automatically run lint checks before commits:
// package.json
{
"scripts": {
"prepare": "husky install",
"lint": "eslint . --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
}
}Conclusion
Writing this far, I suddenly remember the confusion when first encountering Vue 3. Back then Composition API just came out, community was debating whether to use it or stick with Options API, various tutorials varied in quality, stepping on landmines was routine.
This solution isn’t necessarily suitable for all projects. Small projects can totally simplify—for state management, provide/inject is enough, directory structure doesn’t need to be split so finely. But if you’re working on medium-to-large enterprise projects with 3+ team members collaborating, this architecture should save you considerable trouble.
Tech stack selection has no silver bullet—most important is team consensus, forming standards, consistent execution. Hope this article gives you some reference and helps you avoid some detours.
How does your team set up Vue 3 projects? Any good practical experiences? Welcome to share in the comments.
Published on: Nov 24, 2025 · Modified on: Dec 4, 2025
Related Posts

Complete Guide to Deploying Astro on Cloudflare: SSR Configuration + 3x Speed Boost for China

Building an Astro Blog from Scratch: Complete Guide from Homepage to Deployment in 1 Hour
