Nuxt Guide

Composables

Composable conventions, patterns, and a set of reusable primitives.

Conventions

  • All composables are factory functions named use<Domain>().
  • They live in app/composables/ and are auto-imported by Nuxt.
  • Shared composables live in a shared Nuxt layer (app/composables/) and are available in all consuming apps.
  • Do not wrap composables in defineNuxtComposable — plain functions only.
  • Return plain Promises from API call wrappers — don't use useFetch/useAsyncData inside composables. Let pages decide how to handle async.
  • Factories for per-component state — call them inside setup, not at module level, to avoid shared mutable state across components.
  • Reactive state shared across components → use useState('key', ...) with a consistent key string.
  • Never hard-code API paths — always use the /api/ proxy prefix.

useAuth

Central authentication composable — login, logout, token management, and current user state.

// app/composables/useAuth.ts
export default function useAuth() {
  const { cookieConfig } = useRuntimeConfig().public

  const currentUser = useState<TUser | null>('currentUser', () => null)
  const token       = useCookie<string | null>('accessToken', cookieConfig)
  const userId      = useCookie<string | null>('userId', cookieConfig)

  const isLoading = ref(false)
  const error     = ref<string | null>(null)

  async function login(email: string, password: string) {
    try {
      isLoading.value = true
      const response = await useNuxtApp().$api<{
        accessToken: string
        _id: string
      }>('/api/auth/login', { method: 'POST', body: { email, password } })

      token.value  = response.accessToken
      userId.value = response._id
      currentUser.value = await getCurrentUser()

      return response
    } catch (err: any) {
      error.value = err?.data?.message || 'Login failed'
      throw err
    } finally {
      isLoading.value = false
    }
  }

  async function logout() {
    await useNuxtApp().$api('/api/auth/logout', { method: 'DELETE' })
    token.value = null
    userId.value = null
    currentUser.value = null
    await navigateTo('/login')
  }

  async function getCurrentUser() {
    return useNuxtApp().$api<TUser>('/api/auth/user', { method: 'GET' })
  }

  return { currentUser, token, isLoading, error, login, logout, getCurrentUser }
}

useFormHandler

Reusable form helper with built-in validation rules and a standardised API action wrapper with toast feedback.

// app/composables/useFormHandler.ts
export default function useFormHandler() {
  const loading = ref(false)
  const { alertRef } = useUtils()

  const commonValidationRules: TValidationRules = {
    required: (v) => !v || v.trim() === '' ? 'This field is required.' : '',
    email:    (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? '' : 'Invalid email.',
    password: (v) => {
      if (!v)                    return 'Password is required.'
      if (v.length < 8)          return 'Must be at least 8 characters.'
      if (!/[A-Z]/.test(v))      return 'Must contain an uppercase letter.'
      if (!/[0-9]/.test(v))      return 'Must contain a number.'
      if (!/[!@#$%^&*]/.test(v)) return 'Must contain a special character.'
      return ''
    },
  }

  const handleApiAction = async <T>(
    action: () => Promise<T>,
    options: TApiActionOptions = {},
  ): Promise<T | null> => {
    const { successMessage, errorMessage = 'An error occurred.', onSuccess, onError, skipAlert } = options
    loading.value = true
    try {
      const result = await action()
      if (onSuccess) await onSuccess(result)
      if (!skipAlert) alertRef?.value?.triggerAlert(successMessage || 'Success.', 'success')
      return result
    } catch (error: any) {
      const msg = error?.data?.message || error?.message || errorMessage
      if (onError) onError({ ...error, message: msg })
      if (!skipAlert) alertRef?.value?.triggerAlert(msg, 'error')
      throw { ...error, message: msg }
    } finally {
      loading.value = false
    }
  }

  const validateFields = (
    formData: Record<string, any>,
    rules: Record<string, TValidationRule | string>,
    errors: Record<string, string>,
  ): boolean => {
    let isValid = true
    Object.keys(errors).forEach((k) => (errors[k] = ''))
    Object.entries(rules).forEach(([field, ruleKey]) => {
      const rule = typeof ruleKey === 'string' ? commonValidationRules[ruleKey] : ruleKey
      const err  = rule?.(formData[field])
      if (err) { errors[field] = err; isValid = false }
    })
    return isValid
  }

  return { loading, handleApiAction, validateFields, commonValidationRules }
}

Usage in a page:

const { loading, handleApiAction, validateFields } = useFormHandler()

const form   = reactive({ email: '', password: '' })
const errors = reactive({ email: '', password: '' })

async function submit() {
  const valid = validateFields(form, { email: 'email', password: 'password' }, errors)
  if (!valid) return

  await handleApiAction(
    () => useAuth().login(form.email, form.password),
    { successMessage: 'Welcome back!', onSuccess: () => navigateTo('/dashboard') },
  )
}

usePage

Lightweight pagination primitive — keeps page number, total pages, display range, and current items.

// app/composables/usePage.ts
export default function usePage() {
  const page      = ref(1)
  const pages     = ref(0)
  const pageRange = ref('-- - -- of --')
  const items     = ref<Array<Record<string, any>>>([])
  const search    = ref('')
  return { page, pages, pageRange, items, search }
}

Resource Composable Pattern

Each API resource gets its own composable. The standard structure is:

  1. Import shared composables at the top (import usePage from './usePage')
  2. Declare a reactive form object + resetForm() for write operations
  3. API functions are plain function declarations — return raw Promises, let the caller decide how to handle async
  4. Type API calls with Record<string, any> for generic responses, or a concrete declared type for known shapes
  5. Group the return object with inline comments indicating route access level
// app/composables/useResource.ts
import usePage from './usePage'

export default function useResource() {
  const { page, pages, pageRange, items, search } = usePage()

  const form = reactive({
    name: '',
    description: '',
  })

  function resetForm() {
    form.name = ''
    form.description = ''
  }

  function getResources({
    organizationId,
    page = 1,
    limit = 10,
  }: { organizationId?: string; page?: number; limit?: number } = {}) {
    return useNuxtApp().$api<Record<string, any>>('/api/resources', {
      method: 'GET',
      query: { organizationId, page, limit },
    })
  }

  function getResourceById(id: string) {
    return useNuxtApp().$api<Record<string, any>>(`/api/resources/${id}`, {
      method: 'GET',
    })
  }

  function createResource(payload: TResourceCreate) {
    return useNuxtApp().$api<Record<string, any>>('/api/organizations/resources', {
      method: 'POST',
      body: payload,
    })
  }

  function updateResource(id: string, payload: TResourceCreate) {
    return useNuxtApp().$api<Record<string, any>>(`/api/organizations/resources/${id}`, {
      method: 'PUT',
      body: payload,
    })
  }

  function deleteResource(id: string) {
    return useNuxtApp().$api<Record<string, any>>(`/api/organizations/resources/${id}`, {
      method: 'DELETE',
    })
  }

  return {
    form,
    resetForm,
    page,
    pages,
    pageRange,
    search,
    items,

    // Read operations
    getResources,
    getResourceById,

    // Write operations
    createResource,
    updateResource,
    deleteResource,
  }
}

Naming Convention

Function names are resource-specific — prefix every function with the resource name, not generic verbs like getAll or create.

OperationPatternExample
Fetch listget<Resources>getPackages, getBookings
Fetch singleget<Resource>ByIdgetPackageById, getBookingById
Createcreate<Resource>createPackage, createBooking
Updateupdate<Resource>updatePackage, updateBooking
Deletedelete<Resource>deletePackage, deleteBooking
Form resetresetFormresetForm