Vue 3 + TypeScript Best Practices: Enterprise-Projektarchitektur-Leitfaden 2025

Letzten Monat hat unser Team eine Tech-Stack-Abstimmung abgehalten – State Management, Verzeichnisstruktur, TS-Konfiguration: stundenlang Diskussion ohne klares Ergebnis. Beim ersten Setup von Vue 3 + TypeScript habe ich die tsconfig.json mindestens 20 Mal angepasst; auf einem anderen Rechner trat sofort wieder ein Fehler auf.
Dieser Artikel fasst die Lösung zusammen, die wir nach unzähligen Stolpersteinen entwickelt haben. Ob das die optimale Lösung ist, wage ich nicht zu behaupten – in drei mittelgroßen bis großen Projekten lief sie aber ohne größere Probleme. Wenn Sie ein neues Projekt starten oder ein bestehendes architektonisch aufrüsten wollen, soll diese Erfahrung Ihnen einige Umwege ersparen.
Vue-3-Stack-Auswahl 2025
Zuerst zur Stack-Auswahl. Vue 3 ist seit Jahren da – was man 2025 einsetzen sollte, ist inzwischen ziemlich klar.
Unser Team-Standard: Vite + Vue 3 + TypeScript + Pinia + Vue Router 4. Viele nutzen diese Kombination bereits; trotzdem kurz, warum genau diese Tools.
Vite braucht kaum Erklärung – die Developer Experience ist deutlich besser als mit Webpack, Hot Reload praktisch in Sekunden. Vue 3.6 ist zwar noch Alpha, aber Evan You nannte auf der Vue.js Nation 2025 beeindruckende Zahlen: Der Vapor Mode kann 100.000 Komponenten in 100 Millisekunden mounten. Für Production noch nicht relevant, die Richtung stimmt aber.
Besonders Pinia: Beim ersten Blick auf die API dachte ich: „So sollte State Management aussehen.“ Zum Vergleich:
// Vuex (umständlich)
const store = createStore({
state: () => ({ count: 0 }),
mutations: {
increment(state) { state.count++ }
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => commit('increment'), 1000)
}
}
})
// Pinia (übersichtlich)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => count.value++
const incrementAsync = () => setTimeout(increment, 1000)
return { count, increment, incrementAsync }
})
Pinia wiegt nur rund 1,5 KB; die Entwicklung von Vuex 5 ist praktisch zum Stillstand gekommen. Wenn Ihr Projekt nicht tief in Vuex verankert ist und keine Migrationspläne hat, lohnt sich Vuex für Neuprojekte wirklich nicht mehr.
Projektverzeichnis-Struktur
Ich habe zu viele Projekte gesehen, in denen alle Komponenten unter components/ landen – nach drei Monaten findet man selbst die Dateien nicht mehr.
Bei kleinen Projekten geht vieles; bei wachsender Codebasis ohne Konvention wird es chaotisch. Wir haben mehrere Ansätze getestet und uns auf folgende Struktur festgelegt:
src/
├── api/ # API-Schicht
│ ├── modules/ # Nach Geschäftsmodulen
│ │ ├── user.ts
│ │ └── order.ts
│ └── index.ts
├── assets/ # Statische Ressourcen
│ ├── images/
│ └── styles/
├── components/ # Globale wiederverwendbare Komponenten
│ ├── base/ # Basis-Komponenten (Button, Input usw.)
│ └── business/ # Geschäftsbezogene Shared-Komponenten
├── composables/ # Composable-Funktionen
│ ├── useAuth.ts
│ └── useRequest.ts
├── layouts/ # Layout-Komponenten
├── router/ # Router-Konfiguration
│ ├── modules/ # Router-Module
│ └── index.ts
├── stores/ # Pinia State Management
│ ├── modules/
│ └── index.ts
├── types/ # Globale Typdefinitionen
│ ├── api.d.ts
│ └── global.d.ts
├── utils/ # Hilfsfunktionen
├── views/ # Seiten-Komponenten
│ ├── user/
│ └── order/
├── App.vue
└── main.ts
Wichtige Punkte:
Unterschied composables vs. utils: In composables/ liegen Composables mit reaktiver Logik, z. B. useAuth, useRequest; in utils/ reine Hilfsfunktionen wie formatDate, debounce. Beides zu vermischen, macht die Wartung später mühsam.
Aufteilung nach Funktionsdomäne statt Dateityp: Unter api/, stores/ und views/ gibt es je eine Ebene nach Geschäftsmodul. So findet man alles User-bezogene unter user/.
Typen separat: Globale Typen in types/; komponenteninterne Typen im jeweiligen Komponentenfile. Nicht alles in einen types/-Ordner kippen – das wird nur unübersichtlicher.
TypeScript-Typdefinitionen: Best Practices
Ehrlich gesagt schrecken Type-Gymnastik manche ab – für die meisten Fälle reichen diese Szenarien.
Zuerst tsconfig.json – diese Optionen sollten aktiv sein:
{
"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 ist Pflicht – am Anfang viele Fehler, langfristig lohnt es sich. moduleResolution: "bundler" ist relativ neu und passt gut zu Vite.
Typdefinitionen in Vue-Komponenten: Beim ersten defineProps<Props>() war ich kurz verwirrt, wie die Generics übergeben werden. Das ist Compiler-Magie von Vue:
<script setup lang="ts">
// Props-Typdefinition
interface Props {
title: string
count?: number
items: string[]
}
const props = defineProps<Props>()
// Props mit Standardwerten
const propsWithDefaults = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
// Emits-Typdefinition
interface Emits {
(e: 'update', value: string): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// Oder kürzer (Vue 3.3+)
const emit2 = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
</script>
Typische Falle: .vue-Dateien werden in der IDE nicht erkannt. Legen Sie unter src/ eine env.d.ts oder shims-vue.d.ts an:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
Pinia State Management in der Praxis
Nach Vuex-Mutations fühlte sich direktes Ändern des State in Pinia zunächst falsch an – wie ein Regelbruch. Später wurde klar: Mutations sind Erbe der Flux-Architektur; Pinia hat dieses Ballast abgeworfen.
Wir empfehlen Stores im Composition-API-Stil:
// 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 ?? 'Gast')
// 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('Benutzerinformationen konnten nicht geladen werden', error)
}
}
const logout = () => {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
token,
userInfo,
isLoggedIn,
userName,
setToken,
fetchUserInfo,
logout
}
})
Beim Einsatz eine Falle: Destrukturierung verliert Reaktivität. Nutzen Sie storeToRefs():
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Falsch – Reaktivität geht verloren
const { userName, isLoggedIn } = userStore
// Richtig
const { userName, isLoggedIn } = storeToRefs(userStore)
// Actions können direkt destrukturiert werden (normale Funktionen)
const { logout, fetchUserInfo } = userStore
Für Persistenz empfehlen wir pinia-plugin-persistedstate – Konfiguration ist unkompliziert:
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Im Store aktivieren
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: true // oder konkrete Optionen
})
Vue Router 4: typsichere Routen
Router-Guards können süchtig machen – man stopft alles in beforeEach. In einem Projekt hatten wir fast 200 Zeilen: Berechtigungen, Tracking, Titel – erst nach Aufteilung in mehrere Guards war es wartbar.
Zuerst Typdefinitionen für Routen:
// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// RouteMeta erweitern
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: 'Startseite',
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 router
Auth-Guard Beispiel:
// 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()
// Seiten ohne Login direkt durchlassen
if (!to.meta.requiresAuth) {
next()
return
}
// Nicht eingeloggt → Login
if (!userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Rollenprüfung
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()
})
}
Für Router-Module: nach Geschäftsdomain in Dateien aufteilen und in index.ts zusammenführen:
// 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 und Engineering-Setup
Nach ESLint 9 mit Flat Config habe ich die alte Konfiguration einen halben Nachmittag migriert. Die .eslintrc-Welt ist obsolet; so sieht die neue Variante aus:
// 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'
}
}
]
Konflikte zwischen Prettier und ESLint löst eslint-config-prettier – es deaktiviert widersprüchliche ESLint-Regeln.
unplugin-auto-import importiert Vue-, Vue-Router- und Pinia-APIs automatisch:
// 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
}
})
]
})
Danach können Sie ref() und computed() direkt nutzen, ohne manuelle Imports.
Git-Workflow mit husky + lint-staged – Lint vor jedem Commit:
// package.json
{
"scripts": {
"prepare": "husky install",
"lint": "eslint . --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
"*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
}
}
Abschluss
Beim Schreiben fiel mir ein, wie orientierungslos ich mich anfangs in Vue 3 fühlte. Die Composition API war neu, die Community stritt über Options vs. Composition API, Tutorials uneinheitlich – Stolpersteine waren normal.
Diese Architektur passt nicht zu jedem Projekt. Kleine Apps können vereinfachen: State mit provide/inject, flachere Ordnerstruktur. Bei mittleren bis großen Enterprise-Projekten mit Teams ab drei Personen spart sie aber viel Aufwand.
Es gibt keinen Universal-Stack – wichtig sind gemeinsame Entscheidungen, klare Regeln und konsequente Umsetzung. Ich hoffe, dieser Artikel liefert Anhaltspunkte und erspart Ihnen einige Umwege.
Wie baut Ihr Team Vue-3-Projekte auf? Teilen Sie gern Erfahrungen in den Kommentaren.
Vue 3 + TypeScript Enterprise-Projektarchitektur – vollständiger Ablauf
Vom Projektsetup bis zu Code-Standards: Vite, Pinia, Vue Router, ESLint und weitere Best Practices
Estimated time: PT4H
-
1
Step 1: Projektinitialisierung und Stack-Auswahl
Vite-Projekt anlegen: -
2
Step 2: Projektverzeichnis-Struktur
Struktur nach Funktionsdomänen: -
3
Step 3: TypeScript-Typdefinitionen konfigurieren
tsconfig.json – Schlüsseloptionen: -
4
Step 4: Pinia State Management konfigurieren
Store im Composition-API-Stil: -
5
Step 5: const userInfo = ref<UserInfo
null>(null); -
6
Step 6: Vue Router 4 typsicher konfigurieren
RouteMeta erweitern: -
7
Step 7: ESLint 9 und Code-Standards
ESLint 9 Flat Config:
FAQ
Was ist der Unterschied zwischen Pinia und Vuex? Warum Pinia?
API-Vergleich:
Vuex: state, mutations, actions:
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})
Pinia im Composition-API-Stil:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})
Vorteile Pinia:
• Keine mutations (State direkt änderbar)
• Bessere TypeScript-Unterstützung
• Kleineres Bundle
• Intuitivere API
Ohne tiefe Vuex-Bindung und ohne Migrationsplan: für Neuprojekte Vuex vermeiden.
Wie soll die Verzeichnisstruktur aussehen? Unterschied composables vs. utils?
• src/api/modules/ (z. B. user.ts, order.ts)
• src/components/base/ und business/
• src/composables/ (reaktive Composables wie useAuth, useRequest)
• src/utils/ (reine Hilfsfunktionen wie formatDate, debounce)
• src/stores/modules/
• src/views/ (z. B. user/, order/)
• src/types/
Unterschied:
• composables: reaktive Logik
• utils: reine Funktionen ohne Reaktivität
• Vermischen erschwert die Wartung
Vorteil:
• User-bezogene Dateien liegen unter user/ in api, stores und views
Wie konfiguriere ich TypeScript in Vue 3? Was ist wichtig?
• strict: true
• moduleResolution: 'bundler' (neu, gut mit Vite)
• paths: @/* Alias
Vue-Komponenten:
• defineProps<Props>():
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• withDefaults(defineProps<Props>(), { count: 0, items: () => [] })
• defineEmits<Emits>() oder:
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()
.vue-Erkennung:
env.d.ts oder shims-vue.d.ts unter src:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
IDE-Fehler „Modul nicht gefunden“ → meist fehlende Deklaration.
Wie nutze ich Pinia? Worauf achten?
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('Benutzerinformationen konnten nicht geladen werden', error);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})
Hinweise:
• storeToRefs(): const { userName, isLoggedIn } = storeToRefs(userStore)
• Actions direkt: const { logout, fetchUserInfo } = userStore
Persistenz:
• pinia-plugin-persistedstate
• main.ts: pinia.use(piniaPluginPersistedstate)
• persist: true im Store
Mutations in Vuex sind Flux-Erbe – Pinia verzichtet bewusst darauf.
Wie konfiguriere ich ESLint 9 Flat Config? Unterschied zur alten Version?
Neue Konfiguration:
• eslint.config.js
• js.configs.recommended, vue.configs['flat/recommended']
• files: ['**/*.{ts,tsx,vue}']
• languageOptions.parser: vue-eslint-parser
• parserOptions.parser: @typescript-eslint/parser
• plugins: @typescript-eslint
• rules z. B. 'vue/multi-word-component-names': 'off'
Prettier:
• eslint-config-prettier
Auto-Import:
• unplugin-auto-import
• vite.config.ts: AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' })
Git:
• husky + lint-staged
• "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
Wie richte ich typsichere Routen und Auth-Guards in Vue Router 4 ein?
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}
Routen:
• RouteRecordRaw[]
• meta: title, requiresAuth, roles
Auth-Guard:
• setupAuthGuard in router/guards/auth.ts
• beforeEach: to.meta.requiresAuth (öffentliche Seiten durchlassen)
• Nicht eingeloggt: next({ path: '/login', query: { redirect: to.fullPath } })
• Rollen: roles.some(role => userStore.userInfo?.roles?.includes(role))
• Keine Berechtigung → /403
Modularisierung:
• router/modules/*.ts, in index.ts mergen
Lange beforeEach-Blöcke (200+ Zeilen) in mehrere Guards aufteilen.
8 Min. Lesezeit · Veröffentlicht am: 24. Nov. 2025 · Aktualisiert am: 14. Juli 2026
Frontend Framework
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
React-19-Formular noch 30 Zeilen Code? Mit Actions unter 30 Zeilen – 40 % Performance-Gewinn
Tiefgehende Analyse der 6 Kernfeatures von React 19: Actions, use()-Hook, Compiler und mehr. Praxisvergleich zeigt, wie Sie Formularverarbeitung vereinfachen, die Performance optimieren und Server Components sinnvoll einsetzen – basierend auf einer Woche Hands-on-Erfahrung.
Teil 4 von 6
Nächster
Frontend-Performance-Optimierung: Core Web Vitals – Anleitung für Top-Scores
Systematische Optimierung der drei Core-Web-Vitals-Metriken (LCP/INP/CLS): Bildoptimierung, Code-Splitting, Lazy Loading und mehr als 10 praxiserprobte Maßnahmen. In 2 Wochen von 60 auf 90+ Punkte – mit vollständiger Checkliste und typischen Fehlern.
Teil 6 von 6
Ähnliche Beiträge
Blog-Framework-Leitfaden 2025: Hugo, Astro oder Hexo – was passt zu Ihnen?

Blog-Framework-Leitfaden 2025: Hugo, Astro oder Hexo – was passt zu Ihnen?
Mein Blog mit Astro 5 neu gebaut: Lighthouse-Score von 68 auf 100


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen