Skip to content

Frontend Architecture - Nuxt 4

Overview

This document outlines the frontend architecture, conventions, and best practices for our SaaS CRM application built with Nuxt 4.

Technology Stack

Core Technologies

Technology Version Purpose
Nuxt 4.x Full-stack Vue framework
Vue 3.x Reactive UI framework
TypeScript 5.x Type safety
Tailwind CSS 3.x Utility-first CSS
Pinia 2.x State management
VueUse 10.x Composition utilities

Additional Libraries

{
  "dependencies": {
    "@nuxtjs/tailwindcss": "^6.x",
    "@pinia/nuxt": "^0.5.x",
    "@vueuse/nuxt": "^10.x",
    "dayjs": "^1.x",
    "axios": "^1.x",
    "vee-validate": "^4.x",
    "chart.js": "^4.x"
  }
}

Project Structure

Directory Layout

nuxt-app/
├── .nuxt/                  # Build output (git-ignored)
├── .output/                # Production build
├── assets/                 # Uncompiled assets
│   ├── css/
│   │   └── main.css       # Global styles
│   └── images/
├── components/             # Vue components
│   ├── common/            # Shared components
│   ├── features/          # Feature-specific
│   └── layouts/           # Layout components
├── composables/           # Composition functions
│   ├── useAuth.ts
│   ├── useApi.ts
│   └── useNotification.ts
├── layouts/               # App layouts
│   ├── default.vue
│   ├── auth.vue
│   └── dashboard.vue
├── middleware/            # Route middleware
│   ├── auth.ts
│   └── admin.ts
├── pages/                 # File-based routing
│   ├── index.vue
│   ├── dashboard/
│   └── settings/
├── plugins/               # App plugins
│   ├── api.client.ts
│   └── errorHandler.ts
├── public/                # Static files
├── server/                # Server-side code
│   ├── api/              # API routes
│   ├── middleware/       # Server middleware
│   └── utils/            # Server utilities
├── stores/                # Pinia stores
│   ├── auth.ts
│   ├── user.ts
│   └── notification.ts
├── types/                 # TypeScript definitions
│   ├── api.d.ts
│   └── models.d.ts
├── utils/                 # Utility functions
├── app.vue               # Root component
├── nuxt.config.ts        # Nuxt configuration
└── tailwind.config.js    # Tailwind configuration

Component Architecture

Component Structure

<!-- components/features/CustomerList.vue -->
<template>
  <div class="customer-list">
    <CustomerFilter 
      v-model="filters" 
      @update="handleFilterUpdate"
    />
    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      <CustomerCard
        v-for="customer in filteredCustomers"
        :key="customer.id"
        :customer="customer"
        @select="handleCustomerSelect"
      />
    </div>
    <BasePagination
      v-model="currentPage"
      :total="totalPages"
    />
  </div>
</template>

<script setup lang="ts">
import type { Customer, FilterOptions } from '~/types'

// Props
interface Props {
  customers: Customer[]
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  loading: false
})

// Emits
const emit = defineEmits<{
  select: [customer: Customer]
  filter: [filters: FilterOptions]
}>()

// State
const filters = ref<FilterOptions>({})
const currentPage = ref(1)

// Computed
const filteredCustomers = computed(() => {
  return props.customers.filter(customer => {
    // Filter logic
  })
})

const totalPages = computed(() => {
  return Math.ceil(filteredCustomers.value.length / 10)
})

// Methods
const handleFilterUpdate = (newFilters: FilterOptions) => {
  emit('filter', newFilters)
}

const handleCustomerSelect = (customer: Customer) => {
  emit('select', customer)
}

// Lifecycle
onMounted(() => {
  console.log('CustomerList mounted')
})
</script>

<style scoped>
.customer-list {
  @apply p-4 bg-white rounded-lg shadow;
}
</style>

Component Guidelines

Naming Conventions

// Component names: PascalCase
CustomerList.vue
BaseButton.vue
AppHeader.vue

// Props: camelCase
modelValue
isDisabled
hasError

// Events: kebab-case in template, camelCase in script
@update-filter  // template
emit('updateFilter')  // script

// CSS classes: kebab-case
.customer-list
.btn-primary

State Management

Pinia Store Structure

// stores/auth.ts
import { defineStore } from 'pinia'
import type { User, LoginCredentials } from '~/types'

interface AuthState {
  user: User | null
  token: string | null
  isAuthenticated: boolean
  loading: boolean
  error: string | null
}

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  // Getters
  const isAuthenticated = computed(() => !!token.value)
  const userRole = computed(() => user.value?.role || 'guest')
  const hasPermission = computed(() => (permission: string) => {
    return user.value?.permissions?.includes(permission) || false
  })

  // Actions
  async function login(credentials: LoginCredentials) {
    loading.value = true
    error.value = null

    try {
      const { data } = await $fetch('/api/auth/login', {
        method: 'POST',
        body: credentials
      })

      user.value = data.user
      token.value = data.token

      // Save to cookie
      const tokenCookie = useCookie('auth-token')
      tokenCookie.value = data.token

      await navigateTo('/dashboard')
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }

  async function logout() {
    user.value = null
    token.value = null

    const tokenCookie = useCookie('auth-token')
    tokenCookie.value = null

    await navigateTo('/login')
  }

  async function fetchUser() {
    if (!token.value) return

    try {
      const data = await $fetch('/api/auth/me')
      user.value = data
    } catch (err) {
      await logout()
    }
  }

  return {
    // State
    user: readonly(user),
    token: readonly(token),
    loading: readonly(loading),
    error: readonly(error),

    // Getters
    isAuthenticated,
    userRole,
    hasPermission,

    // Actions
    login,
    logout,
    fetchUser
  }
})

Routing & Navigation

Page Structure

<!-- pages/customers/[id].vue -->
<template>
  <div>
    <PageHeader :title="customer?.name" />
    <CustomerDetails :customer="customer" />
  </div>
</template>

<script setup lang="ts">
// Route params
const route = useRoute()
const customerId = computed(() => route.params.id as string)

// Data fetching
const { data: customer, error } = await useFetch(
  `/api/customers/${customerId.value}`
)

// Error handling
if (error.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Customer not found'
  })
}

// Meta tags
useHead({
  title: customer.value?.name || 'Customer',
  meta: [
    { name: 'description', content: `Customer profile for ${customer.value?.name}` }
  ]
})
</script>

Route Middleware

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuthStore()

  if (!isAuthenticated.value) {
    return navigateTo('/login')
  }
})

// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { userRole } = useAuthStore()

  if (userRole.value !== 'admin') {
    throw createError({
      statusCode: 403,
      statusMessage: 'Forbidden'
    })
  }
})

API Integration

Composable for API Calls

// composables/useApi.ts
export const useApi = () => {
  const config = useRuntimeConfig()
  const { token } = useAuthStore()

  const api = $fetch.create({
    baseURL: config.public.apiBase,
    headers: {
      Authorization: token.value ? `Bearer ${token.value}` : ''
    },
    onRequest({ request, options }) {
      // Add timestamp to prevent caching
      options.query = {
        ...options.query,
        _t: Date.now()
      }
    },
    onResponseError({ request, response }) {
      if (response.status === 401) {
        // Token expired, logout
        const { logout } = useAuthStore()
        logout()
      }
    }
  })

  return {
    get: (url: string, options?: any) => api(url, { ...options, method: 'GET' }),
    post: (url: string, body?: any, options?: any) => api(url, { ...options, method: 'POST', body }),
    put: (url: string, body?: any, options?: any) => api(url, { ...options, method: 'PUT', body }),
    delete: (url: string, options?: any) => api(url, { ...options, method: 'DELETE' })
  }
}

// Usage in components
const api = useApi()
const customers = await api.get('/customers')
await api.post('/customers', { name: 'New Customer' })

UI/UX Guidelines

Design System

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          500: '#3b82f6',
          900: '#1e3a8a'
        },
        secondary: {
          50: '#f0fdf4',
          500: '#22c55e',
          900: '#14532d'
        }
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif']
      }
    }
  }
}

Component Library

<!-- components/common/BaseButton.vue -->
<template>
  <button
    :type="type"
    :class="buttonClasses"
    :disabled="disabled || loading"
    @click="$emit('click', $event)"
  >
    <SpinnerIcon v-if="loading" class="w-4 h-4 mr-2" />
    <slot />
  </button>
</template>

<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  type?: 'button' | 'submit' | 'reset'
  disabled?: boolean
  loading?: boolean
  fullWidth?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  type: 'button',
  disabled: false,
  loading: false,
  fullWidth: false
})

const buttonClasses = computed(() => {
  const base = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'

  const variants = {
    primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500',
    secondary: 'bg-secondary-500 text-white hover:bg-secondary-600 focus:ring-secondary-500',
    danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
    ghost: 'bg-transparent hover:bg-gray-100 focus:ring-gray-500'
  }

  const sizes = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-base',
    lg: 'px-6 py-3 text-lg'
  }

  return [
    base,
    variants[props.variant],
    sizes[props.size],
    props.fullWidth && 'w-full',
    (props.disabled || props.loading) && 'opacity-50 cursor-not-allowed'
  ]
})
</script>

Performance Optimization

Code Splitting

// Lazy load heavy components
const HeavyChart = defineAsyncComponent(() => 
  import('~/components/charts/HeavyChart.vue')
)

// Route-based code splitting (automatic in Nuxt)
// pages/reports.vue - only loaded when route is accessed

Image Optimization

<!-- Use Nuxt Image for optimization -->
<NuxtImg
  src="/images/hero.jpg"
  alt="Hero image"
  width="1920"
  height="1080"
  loading="lazy"
  format="webp"
  quality="80"
  :modifiers="{ blur: 10 }"
/>

Caching Strategies

// composables/useCachedData.ts
export const useCachedData = (key: string, fetcher: () => Promise<any>) => {
  const nuxtApp = useNuxtApp()

  // Check if data exists in payload
  if (nuxtApp.payload.data[key]) {
    return nuxtApp.payload.data[key]
  }

  // Fetch and cache
  return useFetch(key, {
    server: true,
    lazy: true,
    getCachedData(key) {
      return nuxtApp.payload.data[key] || nuxtApp.static.data[key]
    }
  })
}

Testing

Unit Testing Setup

// components/CustomerCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CustomerCard from '~/components/CustomerCard.vue'

describe('CustomerCard', () => {
  it('renders customer name', () => {
    const wrapper = mount(CustomerCard, {
      props: {
        customer: {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com'
        }
      }
    })

    expect(wrapper.text()).toContain('John Doe')
    expect(wrapper.text()).toContain('john@example.com')
  })

  it('emits select event on click', async () => {
    const wrapper = mount(CustomerCard, {
      props: {
        customer: { id: 1, name: 'John Doe' }
      }
    })

    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('select')
  })
})

Security Best Practices

XSS Prevention

<!-- Always use v-html carefully -->
<div v-html="sanitizedHtml"></div>

<script setup>
import DOMPurify from 'isomorphic-dompurify'

const userContent = '<script>alert("XSS")</script><p>Hello</p>'
const sanitizedHtml = DOMPurify.sanitize(userContent)
</script>

CSRF Protection

// plugins/csrf.client.ts
export default defineNuxtPlugin(() => {
  const token = useCookie('csrf-token')

  $fetch.create({
    onRequest({ options }) {
      options.headers = {
        ...options.headers,
        'X-CSRF-Token': token.value
      }
    }
  })
})

Development Workflow

Local Development

# Install dependencies
npm install

# Development server with HMR
npm run dev

# Type checking
npm run typecheck

# Linting
npm run lint

# Build for production
npm run build

# Preview production build
npm run preview

Environment Configuration

# .env.local
NUXT_PUBLIC_API_BASE=http://localhost:8000/api
NUXT_PUBLIC_APP_NAME="CRM Development"
NUXT_SESSION_SECRET=your-secret-key

Last Updated: January 2025 Version: 1.0.0