Alternar tema

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

Easton editorial illustration: cache waterfall instrument

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 }
})
1,5 KB
Tamanho do Pinia
Mais simples que o Vuex; como o desenvolvimento do Vuex 5 praticamente parou, o Pinia é recomendado para projetos novos

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. 1

    Step 1: Inicializar o projeto e escolher a stack

    Criar o projeto Vite:
  2. 2

    Step 2: Projetar a estrutura de pastas

    Organize as pastas por domínio funcional:
  3. 3

    Step 3: Configurar os tipos do TypeScript

    Opções essenciais do tsconfig.json:
  4. 4

    Step 4: Configurar o gerenciamento de estado com Pinia

    Prefira definir a store no estilo da Composition API:
  5. 5

    Step 5: const userInfo = ref<UserInfo

    null>(null);
  6. 6

    Step 6: Configurar rotas com tipagem segura no Vue Router 4

    Amplie o tipo de meta das rotas:
  7. 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?
O Pinia ocupa apenas 1,5 KB, enquanto o desenvolvimento do Vuex 5 praticamente parou.

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?
Organize as pastas por domínio funcional, não apenas por tipo de arquivo:
• 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?
Configurações essenciais do tsconfig.json:
• 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?
Prefira definir a store no estilo da Composition API:
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?
Com a adoção do flat config no ESLint 9, a antiga configuração .eslintrc deixou de valer.

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?
Amplie o tipo de meta das rotas:
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

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog