Boas práticas de Vue 3 com TypeScript: arquitetura para projetos empresariais em 2025

No mês passado, nossa equipe fez uma reunião para escolher a stack técnica. O pessoal de frontend passou metade do dia discutindo gerenciamento de estado, estrutura de pastas e configuração do TypeScript sem chegar a um consenso. Quando configurei Vue 3 + TypeScript pela primeira vez, alterei o tsconfig.json mais de 20 vezes; ao trocar de computador, os erros voltaram.
O que segue é a solução que consolidamos depois de enfrentar muitos desses problemas. Não afirmo que seja a arquitetura perfeita, mas ela funcionou sem grandes dificuldades em três projetos de médio e grande porte. Se você está começando um projeto ou pretende reorganizar um sistema antigo, estas escolhas podem poupar alguns desvios.
Como escolher a stack de Vue 3 em 2025
Comecemos pela stack. O Vue 3 já está disponível há alguns anos e, em 2025, as opções adequadas estão bem mais claras.
Nossa configuração padrão hoje é Vite + Vue 3 + TypeScript + Pinia + Vue Router 4. Muita gente já usa essa combinação, mas vale explicar por que escolhemos cada parte.
O Vite dispensa longas apresentações: a experiência de desenvolvimento é muito melhor que a do Webpack, e o hot reload costuma levar apenas alguns instantes. O Vue 3.6 ainda estava em fase alpha, mas os números apresentados por Evan You na Vue.js Nation 2025 chamaram atenção: o Vapor Mode conseguia montar 100 mil componentes em 100 milissegundos. Ainda não era algo para usar em produção, mas indicava uma direção promissora.
O ponto principal é o Pinia. Quando vi o desenho da API pela primeira vez, pensei: “é assim que uma ferramenta de gerenciamento de estado deveria funcionar”. Compare:
// Forma do Vuex (verbosa)
const store = createStore({
state: () => ({ count: 0 }),
mutations: {
increment(state) { state.count++ }
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => commit('increment'), 1000)
}
}
})
// Forma do Pinia (direta)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
const incrementAsync = () => setTimeout(increment, 1000)
return { count, increment, incrementAsync }
})
O Pinia ocupa cerca de 1,5 KB, e o desenvolvimento do Vuex 5 praticamente parou. A menos que seu projeto já esteja profundamente ligado ao Vuex e não exista um plano de migração, não há motivo para escolher Vuex em um projeto novo.
Estrutura de pastas do projeto
Já vi muitos projetos jogarem todos os componentes dentro de components. Três meses depois, nem os próprios autores conseguiam encontrar um arquivo.
Em projetos pequenos, quase qualquer estrutura funciona. À medida que o código cresce, porém, a ausência de regras vira um problema sério. Nossa equipe experimentou algumas abordagens e acabou adotando esta:
src/
├── api/ # Camada de APIs
│ ├── modules/ # Separação por módulo de negócio
│ │ ├── user.ts
│ │ └── order.ts
│ └── index.ts
├── assets/ # Recursos estáticos
│ ├── images/
│ └── styles/
├── components/ # Componentes globais reutilizáveis
│ ├── base/ # Componentes básicos (Button, Input etc.)
│ └── business/ # Componentes reutilizáveis de negócio
├── composables/ # Funções de composição
│ ├── useAuth.ts
│ └── useRequest.ts
├── layouts/ # Componentes de layout das páginas
├── router/ # Configuração das rotas
│ ├── modules/ # Módulos de rotas
│ └── index.ts
├── stores/ # Gerenciamento de estado com Pinia
│ ├── modules/
│ └── index.ts
├── types/ # Definições globais de tipos
│ ├── api.d.ts
│ └── global.d.ts
├── utils/ # Funções utilitárias
├── views/ # Componentes de página
│ ├── user/
│ └── order/
├── App.vue
└── main.ts
Há alguns pontos importantes aqui.
Diferença entre composables e utils: composables reúne funções de composição com lógica reativa, como useAuth e useRequest; utils guarda funções puras, como formatDate e debounce. Misturar essas duas categorias torna a manutenção bem mais difícil depois.
Separação por domínio funcional, não por tipo de arquivo: dentro de api, stores e views, há outro nível por módulo de negócio. Isso deixa a localização dos arquivos previsível — tudo o que diz respeito a usuários fica sob a pasta user.
Tipos em uma área própria: definições globais ficam em types, enquanto tipos internos de um componente podem permanecer no arquivo do componente. Isso não significa concentrar todo e qualquer tipo em uma única pasta, o que só criaria outra confusão.
Boas práticas de tipagem com TypeScript
Sendo franco, a ginástica de tipos do TypeScript pode intimidar. Para a maior parte das necessidades, porém, basta dominar alguns cenários.
Começando pelo tsconfig.json, estas opções devem estar ativadas:
{
"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 é indispensável. Ele produz muitos erros no começo, mas o benefício no longo prazo vale o esforço. moduleResolution: "bundler" é uma opção relativamente nova e combina bem com o Vite.
Em seguida vem a tipagem dos componentes Vue. Na primeira vez que encontrei defineProps<Props>(), levei alguns segundos para entender como o genérico chegava ali. É um recurso do compilador do Vue:
<script setup lang="ts">
// Tipo das props
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
// Props com valores padrão
const propsWithDefaults = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
// Tipo dos emits
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Ou uma forma mais concisa (Vue 3.3+)
const emit2 = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
</script>
Outro ponto que costuma causar erros é o reconhecimento dos arquivos .vue. Se a IDE disser que não encontra o módulo, crie env.d.ts ou shims-vue.d.ts dentro de src:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
Gerenciamento de estado na prática com Pinia
Depois de se acostumar às mutations do Vuex, alterar o state diretamente no Pinia parece uma infração. Com o tempo, ficou claro que as mutations eram uma herança da arquitetura Flux; o Pinia simplesmente removeu essa camada.
Nossa equipe recomenda definir as stores no estilo da 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 ?? '游客')
// 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)
}
}
const logout = () => {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
userName,
setToken,
fetchUserInfo,
logout
}
})
Ao usar a store, atenção a uma armadilha: a desestruturação pode remover a reatividade. Use storeToRefs():
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Incorreto: perde a reatividade após a desestruturação
const { userName, isLoggedIn } = userStore
// Correto
const { userName, isLoggedIn } = storeToRefs(userStore)
// Actions podem ser desestruturadas diretamente, pois são funções comuns
const { logout, fetchUserInfo } = userStore
Para persistência, recomendo o plugin pinia-plugin-persistedstate. A configuração é simples:
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Ativação na store
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: true // Ou configure opções específicas
})
Rotas com tipagem segura no Vue Router 4
É fácil começar a colocar todo tipo de lógica em beforeEach. Em um de nossos projetos, a função chegou perto de 200 linhas: verificações de permissão, analytics e definição do título ficaram amontoados ali. A manutenção só melhorou quando dividimos esse código em várias guardas.
Primeiro, veja a definição de tipos da configuração de rotas:
// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// Amplia o tipo de meta das rotas
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: '首页',
requiresAuth: false
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '控制台',
requiresAuth: true,
roles: ['admin', 'editor']
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
A guarda de permissão pode ser implementada assim:
// 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 que não exigem login seguem diretamente
if (!to.meta.requiresAuth) {
next()
return
}
// Redireciona usuários não autenticados para a página de login
if (!userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Verificação de permissão
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 as rotas, recomendo separá-las por domínio de negócio e depois combiná-las em 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')
}
]
}
]
Padronização do código e configuração das ferramentas
Quando o ESLint 9 adotou o flat config, passei uma tarde inteira migrando a configuração antiga. O formato .eslintrc deixou de valer; a nova configuração fica assim:
// 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'
}
}
]
Use eslint-config-prettier para resolver conflitos entre Prettier e ESLint. O pacote desativa as regras do ESLint que entram em conflito com a formatação do Prettier.
O unplugin-auto-import automatiza os imports das APIs do Vue, Vue Router e Pinia, eliminando várias linhas repetitivas:
// 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
}
})
]
})
Depois dessa configuração, basta escrever ref() e computed() no código, sem imports manuais.
Para padronizar os commits Git, use husky + lint-staged e execute o lint automaticamente 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"]
}
}
Considerações finais
Ao terminar este texto, lembrei da insegurança de quando comecei a trabalhar com Vue 3. A Composition API tinha acabado de chegar, a comunidade discutia se valia adotá-la ou permanecer na Options API, e a qualidade dos tutoriais variava muito. Encontrar problemas era rotina.
Esta arquitetura não serve para todo projeto. Em aplicações pequenas, você pode simplificar bastante: provide/inject pode bastar para o estado, e as pastas não precisam de tantos níveis. Para um projeto empresarial de médio ou grande porte, com uma equipe de três pessoas ou mais, porém, essa organização tende a reduzir bastante o atrito.
Não existe solução universal para escolhas técnicas. O mais importante é a equipe chegar a um consenso, registrar as regras e segui-las. Espero que estas práticas ofereçam uma referência útil e evitem alguns dos problemas que já enfrentamos.
Como sua equipe estrutura projetos Vue 3? Compartilhe outras práticas nos comentários.
Como estruturar um projeto empresarial com Vue 3 e TypeScript
Configuração completa, da inicialização do projeto aos padrões de código, com boas práticas para Vite, Pinia, Vue Router e ESLint
Estimated time: PT4H
-
1
Step 1: Inicializar o projeto e escolher a stack
Criar o projeto Vite: -
2
Step 2: Projetar a estrutura de pastas
Organize as pastas por domínio funcional: -
3
Step 3: Configurar os tipos do TypeScript
Opções essenciais do tsconfig.json: -
4
Step 4: Configurar o gerenciamento de estado com Pinia
Prefira definir a store no estilo da Composition API: -
5
Step 5: const userInfo = ref<UserInfo
null>(null); -
6
Step 6: Configurar rotas com tipagem segura no Vue Router 4
Amplie o tipo de meta das rotas: -
7
Step 7: Configurar o ESLint 9 e os padrões de código
O ESLint 9 usa flat config:
FAQ
Qual é a diferença entre Pinia e Vuex, e por que usar Pinia?
Comparação das APIs:
O Vuex exige três camadas: state, mutations e actions:
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})
O Pinia é mais direto com o estilo da Composition API:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})
Vantagens do Pinia:
• não exige mutations, pois o state pode ser alterado diretamente
• oferece melhor suporte a TypeScript
• tem tamanho menor
• apresenta uma API mais intuitiva
A menos que o projeto já dependa muito do Vuex e não tenha um plano de migração, não há motivo para adotá-lo em um projeto novo.
Como organizar as pastas de um projeto Vue 3? Qual é a diferença entre composables e utils?
• src/api/modules/ (separado por módulos de negócio, como user.ts e order.ts)
• src/components/base/ e business/ (componentes básicos e componentes reutilizáveis de negócio)
• src/composables/ (funções de composição com lógica reativa, como useAuth e useRequest)
• src/utils/ (funções utilitárias puras, como formatDate e debounce)
• src/stores/modules/ (estado do Pinia separado por módulo de negócio)
• src/views/ (páginas separadas por módulo, como user/ e order/)
• src/types/ (definições globais de tipos)
Diferença entre composables e utils:
• composables contém funções de composição com lógica reativa, como useAuth e useRequest
• utils contém funções puras, como formatDate e debounce
• misturar os dois costuma dificultar bastante a manutenção com o tempo
Vantagens da separação por domínio funcional:
• api, stores e views ganham mais um nível organizado por módulo de negócio
• assim, tudo o que está relacionado a usuários, por exemplo, fica sob a pasta user
Como configurar TypeScript no Vue 3 e quais pontos merecem atenção?
• ative strict: true — no começo aparecem muitos erros, mas o benefício no longo prazo compensa
• use moduleResolution: 'bundler', uma opção mais recente que funciona bem com Vite
• configure o alias @/* em paths
Tipagem de componentes Vue:
• use a sintaxe genérica defineProps<Props>():
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• para valores padrão, use withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• defina o tipo de emits com defineEmits<Emits>() ou com a forma mais concisa:
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()
Reconhecimento de arquivos .vue:
Crie env.d.ts ou shims-vue.d.ts dentro de src:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
Se a IDE disser que não encontra o módulo, normalmente esse arquivo está ausente ou mal configurado.
Como usar o Pinia para gerenciar estado e quais cuidados são necessários?
use 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);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})
Cuidados no uso:
• use storeToRefs() ao desestruturar valores reativos: const { userName, isLoggedIn } = storeToRefs(userStore)
• actions podem ser desestruturadas diretamente, pois são funções comuns: const { logout, fetchUserInfo } = userStore
Persistência:
• instale pinia-plugin-persistedstate
• execute pinia.use(piniaPluginPersistedstate) em main.ts
• ative persist: true na store ou configure opções específicas
Depois de se acostumar com mutations no Vuex, alterar o state diretamente no Pinia pode parecer errado. Na prática, aquela regra veio da arquitetura Flux, e o Pinia eliminou essa camada desnecessária.
Como configurar o flat config do ESLint 9 e o que mudou em relação às versões anteriores?
Nova configuração:
• crie eslint.config.js
• use js.configs.recommended e vue.configs['flat/recommended']
• configure files: ['**/*.{ts,tsx,vue}']
• defina languageOptions.parser como vue-eslint-parser
• defina parserOptions.parser como @typescript-eslint/parser
• configure o plugin @typescript-eslint
• adicione as regras necessárias, como 'vue/multi-word-component-names': 'off' e '@typescript-eslint/no-unused-vars': 'warn'
Integração com Prettier:
• use eslint-config-prettier para evitar conflitos entre ESLint e Prettier
• esse pacote desativa regras do ESLint que entram em conflito com o Prettier
Imports automáticos:
• instale unplugin-auto-import
• configure AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' }) em vite.config.ts
• depois disso, ref() e computed() podem ser usados sem import manual
Padrão de commits Git:
• configure husky + lint-staged
• configure lint-staged no package.json: "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
• execute a verificação de lint automaticamente antes de cada commit
Como configurar rotas com tipagem segura e guardas de permissão no Vue Router 4?
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}
Configuração das rotas:
• use o tipo RouteRecordRaw[]
• defina title, requiresAuth e roles em meta
Guarda de permissão:
• crie a função setupAuthGuard em router/guards/auth.ts
• use router.beforeEach para verificar to.meta.requiresAuth; páginas sem exigência de login seguem diretamente
• redirecione quem não está autenticado: next({ path: '/login', query: { redirect: to.fullPath } })
• confira as permissões com roles: const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
• redirecione para 403 quando faltar permissão
Modularização das rotas:
• separe os arquivos por domínio de negócio, como router/modules/user.ts
• faça a combinação em index.ts
É fácil acabar colocando lógica demais em beforeEach. Em um de nossos projetos, a função chegou perto de 200 linhas, reunindo permissões, analytics e título da página; a manutenção só melhorou quando dividimos tudo em várias guardas.
10 min de leitura · Publicado em: 24 nov 2025 · Atualizado em: 8 set 2026
Framework frontend
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Formulários no React 19 ainda exigem 30 linhas? Actions resolve e melhora o desempenho em 40%
Uma análise aprofundada de seis recursos centrais do React 19, incluindo Actions, o Hook use() e o Compiler. Compare código prático para simplificar formulários, melhorar o desempenho e usar Server Components, com aprendizados de uma semana de testes.
Parte 3 de 5
Próximo
Otimização de desempenho frontend na prática: guia para nota máxima no Core Web Vitals
Um guia sistemático para otimizar as três métricas do Core Web Vitals (LCP, INP e CLS), com mais de 10 soluções práticas, como otimização de imagens, divisão de código e carregamento preguiçoso. Veja como elevar a nota de 60 para mais de 90 em duas semanas, com checklist completo e armadilhas a evitar.
Parte 5 de 5



Comentários
Entre com GitHub para comentar