Share
Engineering

Vue.js and Nuxt Best Practices for Enterprise Applications

Vue.js and Nuxt Best Practices for Enterprise Applications

From 11 Seconds to 1.2: A Dashboard Performance Transformation

A SaaS platform dashboard was a performance disaster. Users stared at a loading spinner for 11 seconds before seeing any content. The bundle was 4.2MB. Every page navigation triggered a full re-render. Customer complaints were mounting.

Six weeks of systematic optimization later, first contentful paint happens in 1.2 seconds. The initial bundle is 180KB. Navigation feels instant. The same codebase, completely transformed through Vue.js and Nuxt best practices.

Here's exactly what changed.

Component Architecture That Scales

The Single-File Component Contract

Every component in a well-architected Vue application should follow this structure:

<script setup>
// 1. Imports
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'

// 2. Props definition
const props = defineProps({
userId: {
type: String,
required: true
},
showDetails: {
type: Boolean,
default: false
}
})

// 3. Emits definition
const emit = defineEmits(['update', 'delete'])

// 4. Composables
const userStore = useUserStore()

// 5. Reactive state
const isLoading = ref(false)
const userData = ref(null)

// 6. Computed properties
const displayName = computed(() => {
return userData.value?.name || 'Unknown User'
})

// 7. Methods
async function fetchUser() {
isLoading.value = true
try {
userData.value = await userStore.fetchById(props.userId)
} finally {
isLoading.value = false
}
}

// 8. Lifecycle hooks
onMounted(() => {
fetchUser()
})
</script>

<template>
<div class="user-card">
<LoadingSpinner v-if="isLoading" />
<template v-else>
<h2>{{ displayName }}</h2>
<!-- Component content -->
</template>
</div>
</template>

<style scoped>
.user-card {
/ Scoped styles /
}
</style>

This order isn't arbitrary. It mirrors the mental model of understanding a component: what it depends on, what it accepts, what it emits, what state it manages, and what side effects it performs.

Component Size Guidelines

Maximum 300 lines per component. If a component grows beyond this, it's doing too much.

Split signals:

  • Multiple unrelated pieces of state

  • Template sections that could render independently

  • Logic that could be reused elsewhere

Extraction pattern:

UserDashboard.vue (300+ lines)

├── UserHeader.vue (50 lines)
├── UserStats.vue (80 lines)
├── UserActivity.vue (120 lines)
└── useUserDashboard.js (composable with shared logic)

The Composables Pattern

Extract reusable logic into composables. Not everything needs to be in a component.

// composables/useAsyncData.js
import { ref, readonly } from 'vue'

export function useAsyncData(fetchFn) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)

async function execute(...args) {
isLoading.value = true
error.value = null
try {
data.value = await fetchFn(...args)
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
}

return {
data: readonly(data),
error: readonly(error),
isLoading: readonly(isLoading),
execute
}
}

// Usage in component
const { data: users, isLoading, execute: fetchUsers } = useAsyncData(api.getUsers)

Performance Optimization Techniques

Lazy Loading Components

Never load what you don't need. Nuxt makes this trivial:

<script setup>
// Lazy load heavy components
const HeavyChart = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
</script>

<template>
<div>
<Suspense>
<HeavyChart v-if="showChart" :data="chartData" />
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</div>
</template>

In Nuxt, prefix components with Lazy for automatic lazy loading:

<template>
<!-- Automatically lazy loaded -->
<LazyHeavyChart v-if="showChart" />
</template>

Virtualization for Long Lists

Rendering 10,000 items? Don't. Virtualize.

<script setup>
import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(
allItems,
{ itemHeight: 50 }
)
</script>

<template>
<div v-bind="containerProps" class="list-container">
<div v-bind="wrapperProps">
<div
v-for="{ data, index } in list"
:key="index"
class="list-item"
>
{{ data.name }}
</div>
</div>
</div>
</template>

Our activity feed went from 2.3 seconds to render 5,000 items to 16ms.

Reactive State Optimization

// BAD: Entire object is reactive, triggers updates on any change
const user = reactive({
profile: { name: '', email: '' },
settings: { theme: 'dark', notifications: true },
activity: [] // Large array, frequently updated
})

// GOOD: Separate refs for unrelated data
const userProfile = ref({ name: '', email: '' })
const userSettings = ref({ theme: 'dark', notifications: true })
const userActivity = shallowRef([]) // shallowRef for large arrays

// Update without triggering deep reactivity
userActivity.value = [...newActivityItems]

Computed Property Caching

Computed properties cache their results. Use them.

// BAD: Recalculates on every render
const filteredItems = items.value.filter(i => i.status === 'active')

// GOOD: Only recalculates when items or filter changes
const filteredItems = computed(() =>
items.value.filter(i => i.status === activeFilter.value)
)

Nuxt-Specific Best Practices

Data Fetching Patterns

<script setup>
// For data needed immediately on page load
const { data: products, pending, error } = await useFetch('/api/products', {
key: 'products',
transform: (data) => data.items // Transform response
})

// For data that can load after initial render
const { data: recommendations, refresh } = await useLazyFetch('/api/recommendations')

// For cached data with SWR-like behavior
const { data: user } = await useAsyncData('user', () => $fetch('/api/user'), {
getCachedData: (key) => nuxtApp.payload.data[key] // Use cached if available
})
</script>

Route-Level Code Splitting

Nuxt 3 automatically code-splits by route. Enhance with explicit chunking:

// nuxt.config.ts
export default defineNuxtConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-charts': ['chart.js', 'd3'],
'vendor-editor': ['monaco-editor'],
}
}
}
}
}
})

Server Routes for API Aggregation

Don't make clients call multiple APIs. Aggregate on the server:

// server/api/dashboard.get.ts
export default defineEventHandler(async (event) => {
const userId = event.context.user.id

// Parallel fetches on the server
const [stats, activity, notifications] = await Promise.all([
fetchUserStats(userId),
fetchUserActivity(userId),
fetchUserNotifications(userId)
])

return { stats, activity, notifications }
})

One client request, one server response, three parallel internal fetches.

State Management at Scale

Pinia Store Structure

// stores/products.js
import { defineStore } from 'pinia'

export const useProductStore = defineStore('products', () => {
// State
const items = ref([])
const isLoading = ref(false)
const filter = ref({ category: null, priceRange: null })

// Getters
const filteredItems = computed(() => {
return items.value.filter(item => {
if (filter.value.category && item.category !== filter.value.category) {
return false
}
if (filter.value.priceRange) {
const [min, max] = filter.value.priceRange
if (item.price < min || item.price > max) return false
}
return true
})
})

const totalValue = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)

// Actions
async function fetchProducts() {
isLoading.value = true
try {
items.value = await $fetch('/api/products')
} finally {
isLoading.value = false
}
}

function setFilter(newFilter) {
filter.value = { ...filter.value, ...newFilter }
}

return {
items: readonly(items),
isLoading: readonly(isLoading),
filter,
filteredItems,
totalValue,
fetchProducts,
setFilter
}
})

When to Use Store vs. Composable

Use a store when:

  • State is shared across multiple unrelated components

  • State needs to persist across route navigations

  • You need devtools integration for debugging

Use a composable when:

  • Logic is reusable but state is component-local

  • State doesn't need to persist across navigations

  • Simpler testing requirements

Error Handling and Resilience

Global Error Handler

// plugins/error-handler.client.js
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
// Log to monitoring service
console.error('Vue error:', error, info)

// Show user-friendly error
const toast = useToast()
toast.error('Something went wrong. Please try again.')
}

// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason)
})
})

Component Error Boundaries

<script setup>
import { onErrorCaptured, ref } from 'vue'

const error = ref(null)

onErrorCaptured((err) => {
error.value = err
return false // Prevent propagation
})
</script>

<template>
<div v-if="error" class="error-boundary">
<p>Something went wrong in this section.</p>
<button @click="error = null">Retry</button>
</div>
<slot v-else />
</template>

Testing Strategy

Unit Testing Components

// components/__tests__/UserCard.spec.js
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import UserCard from '../UserCard.vue'

describe('UserCard', () => {
it('displays user name when loaded', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
global: {
stubs: ['LoadingSpinner']
}
})

// Wait for async operations
await wrapper.vm.$nextTick()

expect(wrapper.text()).toContain('John Doe')
})

it('emits delete event when button clicked', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' }
})

await wrapper.find('[data-test="delete-btn"]').trigger('click')

expect(wrapper.emitted('delete')).toBeTruthy()
})
})

E2E Testing with Playwright

// e2e/dashboard.spec.js
import { test, expect } from '@playwright/test'

test('dashboard loads and displays data', async ({ page }) => {
await page.goto('/dashboard')

// Wait for loading to complete
await expect(page.locator('[data-test="loading"]')).toBeHidden()

// Verify data is displayed
await expect(page.locator('[data-test="stats-card"]')).toBeVisible()
await expect(page.locator('[data-test="activity-feed"]')).toHaveCount(10)
})

The 11-second load time wasn't caused by one thing. It was a hundred small decisions accumulating. And the 1.2-second result wasn't achieved through one optimization. It was a hundred small improvements, each guided by measurement and the practices outlined here.

Performance isn't a feature you add at the end. It's a discipline you maintain from the start.

Ricardo Mendes

About the Author

Ricardo Mendes

Co-founder of AIOBI. Computer Engineer with experience in data analysis, software, and financial management.