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