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/useAsyncDatainside 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:
- Import shared composables at the top (
import usePage from './usePage') - Declare a
reactiveform object +resetForm()for write operations - API functions are plain
functiondeclarations — return raw Promises, let the caller decide how to handle async - Type API calls with
Record<string, any>for generic responses, or a concrete declared type for known shapes - 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.
| Operation | Pattern | Example |
|---|---|---|
| Fetch list | get<Resources> | getPackages, getBookings |
| Fetch single | get<Resource>ById | getPackageById, getBookingById |
| Create | create<Resource> | createPackage, createBooking |
| Update | update<Resource> | updatePackage, updateBooking |
| Delete | delete<Resource> | deletePackage, deleteBooking |
| Form reset | resetForm | resetForm |