Composables
TL;DR
Composables are reusable functions that encapsulate reactive logic. Nuxt auto-imports from composables/ (top-level files and named exports from subdirectory index.ts files) and utils/ (pure helpers). useRuntimeConfig provides environment-specific settings; useAppConfig provides bundled app settings. The use prefix is convention β any exported function from these directories is auto-imported.
Mental Model
Two directories, two purposes:
composables/β reactive logic functions (typicallyuseprefixed). These wrapref,computed,watch,useFetch,useState. Theyβre the building blocks of your feature logic.utils/β pure helper functions. Formatters, validators, parsers. No Vue reactivity inside. Think lodash-style utilities.
Both are auto-imported app-wide. You never write import { useCounter } from '~/composables/useCounter' β itβs just available.
Scan rules:
composables/scans top-level files andindex.tsnamed exports from subdirectoriesutils/scans the same way- Nested files that arenβt re-exported from an index are NOT auto-imported
composables/
βββ useCounter.ts β Auto-imported (top-level)
βββ useAuth.ts β Auto-imported (top-level)
βββ helpers.ts β Auto-imported (top-level, all exports)
βββ api/
βββ index.ts β Named exports auto-imported
βββ useUsers.ts β NOT auto-imported (nested, no index re-export)
βββ usePosts.ts β NOT auto-imported (nested, no index re-export)
To auto-import from subdirectories, re-export from index.ts:
// composables/api/index.ts
export { useUsers } from './useUsers'
export { usePosts } from './usePosts'
Or configure explicit directory scanning in nuxt.config.ts:
export default defineNuxtConfig({
imports: {
dirs: ['composables/**'], // Scan all subdirectories
},
})
Writing Custom Composables
The core pattern:
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initial }
return { count, doubled, increment, decrement, reset }
}
Principles:
- Start with
useprefix β convention that signals βthis is a composable with reactive behaviorβ - Return reactive state and methods β consumers destructure what they need
- Single responsibility β one composable, one concern.
useAuth,useCart,useSearchβ notuseEverything - Composable composition β composables can use other composables internally
// composables/useApi.ts β composes useRuntimeConfig + useFetch
export function useApi<T>(path: string, options?: Parameters<typeof useFetch>[1]) {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase
return useFetch<T>(`${baseURL}${path}`, {
...options,
headers: {
...options?.headers,
'X-App-Version': '1.0.0',
},
})
}
Shared State Composable Pattern
The most powerful Nuxt composable pattern β wraps useState for shared reactive state:
// composables/useNotifications.ts
interface Notification {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
timeout?: number
}
export function useNotifications() {
const notifications = useState<Notification[]>('notifications', () => [])
function show(type: Notification['type'], message: string, timeout = 5000) {
const id = crypto.randomUUID()
notifications.value.push({ id, type, message, timeout })
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout)
}
return id
}
function dismiss(id: string) {
notifications.value = notifications.value.filter(n => n.id !== id)
}
function clear() {
notifications.value = []
}
// Convenience methods
const success = (msg: string) => show('success', msg)
const error = (msg: string) => show('error', msg, 0) // No auto-dismiss for errors
const warning = (msg: string) => show('warning', msg)
const info = (msg: string) => show('info', msg)
return { notifications, show, dismiss, clear, success, error, warning, info }
}
Every component that calls useNotifications() shares the same state. The header shows the notification list, a form handler triggers error(), a success page triggers success() β all coordinated without prop drilling or event buses.
Async Composable Pattern
Wrap data fetching logic for reuse across pages:
// composables/useUsers.ts
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export function useUsers() {
const { data: users, pending, error, refresh } = useFetch<User[]>('/api/users')
const admins = computed(() =>
users.value?.filter(u => u.role === 'admin') ?? []
)
async function createUser(payload: Omit<User, 'id'>) {
const newUser = await $fetch<User>('/api/users', {
method: 'POST',
body: payload,
})
refresh()
return newUser
}
async function deleteUser(id: number) {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
refresh()
}
return { users, admins, pending, error, refresh, createUser, deleteUser }
}
Now any page can const { users, createUser, deleteUser } = useUsers() and get the full CRUD interface with caching and shared state.
useRuntimeConfig
Access values from nuxt.config.ts runtimeConfig:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiSecret: '', // Server-only (NUXT_API_SECRET)
public: {
apiBase: '/api', // Client + server (NUXT_PUBLIC_API_BASE)
appVersion: '1.0.0',
},
},
})
// In a composable or component (client + server)
const config = useRuntimeConfig()
config.public.apiBase // β available
config.public.appVersion // β available
config.apiSecret // β undefined on client, β on server
// In a server route
export default defineEventHandler(() => {
const config = useRuntimeConfig()
config.apiSecret // β available (server-only)
})
When to use runtimeConfig:
- API base URLs that change per environment
- Feature flags that toggle per deployment
- Third-party service keys (private only!)
- Any value that should be overridable via environment variables without rebuilding
useAppConfig
Access values from app.config.ts (a separate file from nuxt.config.ts):
// app.config.ts
export default defineAppConfig({
ui: {
primaryColor: '#3b82f6',
borderRadius: '0.5rem',
darkMode: false,
},
site: {
name: 'My App',
description: 'A dense Nuxt app',
},
})
<script setup lang="ts">
const appConfig = useAppConfig()
// appConfig.ui.primaryColor β '#3b82f6'
// Reactive! Hot-reloaded in dev when you edit app.config.ts
</script>
runtimeConfig vs appConfig:
| Β | runtimeConfig | appConfig |
|---|---|---|
| Source file | nuxt.config.ts | app.config.ts |
| Env var override | β (NUXT_ prefix) | β |
| Bundled in app | Public keys only | Entirely |
| Reactive in dev | β (restart needed) | β (hot-reloaded) |
| Use for | Secrets, per-env URLs, feature flags | Theme, UI settings, branding |
| Private keys | β (server-only) | β (everything is public) |
Rule: If itβs a secret or per-environment, use runtimeConfig. If itβs UI/theme configuration thatβs the same across all environments, use appConfig.
utils/ Directory
For pure helper functions that donβt use Vue reactivity:
// utils/formatDate.ts
export function formatDate(date: string | Date, locale = 'en-US'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(date))
}
// utils/formatCurrency.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount)
}
// utils/slugify.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
Auto-imported everywhere β use directly in templates and scripts:
<template>
<time>{{ formatDate(post.createdAt) }}</time>
<span>{{ formatCurrency(product.price) }}</span>
</template>
Factory Composable Pattern
When you need configured instances:
// composables/useForm.ts
interface FormOptions<T> {
initialValues: T
validate?: (values: T) => Record<string, string>
onSubmit: (values: T) => Promise<void>
}
export function useForm<T extends Record<string, any>>(options: FormOptions<T>) {
const values = ref({ ...options.initialValues }) as Ref<T>
const errors = ref<Record<string, string>>({})
const submitting = ref(false)
const dirty = ref(false)
watch(values, () => { dirty.value = true }, { deep: true })
function setError(field: string, message: string) {
errors.value[field] = message
}
function clearErrors() {
errors.value = {}
}
async function submit() {
clearErrors()
if (options.validate) {
const validationErrors = options.validate(values.value)
if (Object.keys(validationErrors).length > 0) {
errors.value = validationErrors
return
}
}
submitting.value = true
try {
await options.onSubmit(values.value)
} finally {
submitting.value = false
}
}
function reset() {
values.value = { ...options.initialValues }
clearErrors()
dirty.value = false
}
return { values, errors, submitting, dirty, submit, reset, setError, clearErrors }
}
Usage:
<script setup lang="ts">
const { values, errors, submitting, submit } = useForm({
initialValues: { email: '', password: '' },
validate: (v) => {
const e: Record<string, string> = {}
if (!v.email) e.email = 'Required'
if (v.password.length < 8) e.password = 'Min 8 characters'
return e
},
onSubmit: async (data) => {
await $fetch('/api/auth/login', { method: 'POST', body: data })
navigateTo('/dashboard')
},
})
</script>
Composables in the Nuxt Lifecycle
Composables that use Nuxt-specific features (useFetch, useState, useRoute) must be called during the setup phase β the synchronous execution of <script setup> or setup():
// β Called during setup
const { data } = await useFetch('/api/data')
// β Called asynchronously β WILL FAIL
onMounted(async () => {
const { data } = await useFetch('/api/data') // Error: Nuxt context not available
})
// β Called in a callback β WILL FAIL
const onClick = async () => {
const route = useRoute() // Error: composable called outside setup
}
For event handlers, use $fetch (not useFetch) and access route/config during setup:
// β Correct pattern
const route = useRoute() // Called during setup
const config = useRuntimeConfig() // Called during setup
const onClick = async () => {
// Use the refs captured during setup
await $fetch(`${config.public.apiBase}/action/${route.params.id}`, {
method: 'POST',
})
}
Walkthrough
-
Create
composables/useCounter.tsβ reactive counter with increment/decrement/reset. Use from two different pages, verify shared instance behavior (withuseState) vs separate instances (withref). -
Create
composables/useApi.tsβ wrapsuseFetchwithruntimeConfig.public.apiBase. Use it:const { data } = useApi<User[]>('/users'). -
Create
utils/formatDate.tsβ use directly in templates without import. -
Create
app.config.tsβ add UI theme settings. UseuseAppConfig()in a component. Edit the config file β watch the value hot-reload. -
Create a notification composable β shared state with
useState, methods to show/dismiss, auto-dismiss timeout. Display in layout, trigger from pages.
Gotchas
- Composables must be called synchronously in setup. Not inside
setTimeout,onMountedcallbacks, event handlers, orthen()chains. Capture refs during setup, use them later. composables/subdirectories are NOT auto-scanned by default. Only top-level files andindex.tsre-exports work. Configureimports.dirsto scan deeper, or use index barrel files.useRuntimeConfig()private keys are server-only. Accessing them on the client returnsundefined. TypeScript wonβt warn you β the types include all keys. Guard withimport.meta.serverif needed.- Donβt use Nuxt composables in
server/routes.useRoute(),useFetch(),useState()are Vue-side composables. Server routes use H3 utilities anduseRuntimeConfig()(which works in both contexts). For shared logic in server routes, useserver/utils/. utils/function names can conflict. If you haveutils/format.tsexportingformat()and a library also exportsformat, youβll get conflicts. Use descriptive names:formatDate,formatCurrency,formatBytes.- Composables that return
useFetchmust be awaited at top level.const { data } = await useUsers()β if the composable internally callsuseFetch, it returns a promise. Forgettingawaitmeansdatais the promise, not the ref. - No circular dependencies. If composable A imports composable B which imports composable A β build fails. Structure your composables as a DAG (directed acyclic graph).
useAppConfigis NOT overridable at runtime. Values are bundled at build time. If you need per-environment values, useruntimeConfiginstead.
Exercise
Create three composables and wire them together:
composables/useNotifications.tsβ shared toast notification state:show(type, message, timeout?)β returns notification IDdismiss(id)β removes notificationsuccess(msg),error(msg),warning(msg)β convenience methods- Auto-dismiss after timeout (except errors)
composables/useApi.tsβ configurable fetch wrapper:- Reads
apiBasefromuseRuntimeConfig().public - Wraps
useFetchwith base URL prepended - On error, auto-calls
useNotifications().error()with the error message - Returns the standard
{ data, pending, error, refresh }interface
- Reads
utils/debounce.tsβ standard debounce helper:debounce(fn, ms)β debounced function- Use in a search input that calls
useApion debounced input change
Build a page that demonstrates all three working together: a search input (debounced) that fetches results (useApi) and shows error notifications (useNotifications) on failure.