The composables folder: auto-imports that scale
By day 87, you've likely built enough Nuxt projects to recognize that copy-pasting logic across pages and components becomes a maintenance nightmare. The Nuxt composables folder: real-world lessons and examples teaches us that organizing reusable logic is not optional—it's the difference between a codebase that grows cleanly and one that spirals into technical debt. We'll cover how Nuxt's auto-import system for composables works, when to reach for them, and the pitfalls we've hit in production.
How composables/ auto-imports exports
Nuxt has a convention: any file you place in the composables/ directory is automatically imported where you need it. No manual import statements. This is powered by Nuxt's unplugin system, which scans your composables folder at build time and registers each export as a global auto-import.
Consider a typical composable file:
// composables/useCart.ts
import { ref, computed } from 'vue'
export const useCart = () => {
const items = ref([])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
const addItem = (product) => {
items.value.push(product)
}
const clear = () => {
items.value = []
}
return {
items,
total,
addItem,
clear
}
}
Once this file exists, you can call useCart() in any component without an explicit import:
// components/ShoppingCart.vue
<script setup lang="ts">
const { items, total, addItem, clear } = useCart()
</script>
<template>
<div>
<p>Total: {{ total }}</p>
<button @click="clear">Clear</button>
</div>
</template>
Under the hood, Nuxt generates a virtual composables module at build time. When you reference useCart, it's available globally. This is different from manual imports; it's convention-based and zero-friction. The auto-import system also covers nested subdirectories, so composables/auth/useUser.ts exports a useUser function automatically.
Name with use prefix
We've learned the hard way: always prefix composable names with use. This isn't just style—it's signal. When a developer sees useAuth(), they know immediately that it's a composable (stateful, reactive logic), not a utility function. This convention prevents confusion in large codebases.
Compare these two approaches:
| Without prefix | With prefix |
|---|---|
fetchUserData() |
useUserData() |
validateForm() |
useFormValidation() |
sortItems() |
useSortedItems() |
The use prefix also ensures that linters and TypeScript tooling recognize your composable as such. Many ESLint configurations for Vue/Nuxt will flag composables without the prefix, keeping your codebase consistent.
Here's a practical example in a CRM context:
// composables/useCustomerSearch.ts
import { ref } from 'vue'
import { useAsyncData } from '#app'
export const useCustomerSearch = (apiBase: string) => {
const query = ref('')
const results = ref([])
const isLoading = ref(false)
const search = async () => {
if (!query.value.trim()) {
results.value = []
return
}
isLoading.value = true
try {
const res = await fetch(`${apiBase}/customers/search?q=${query.value}`)
results.value = await res.json()
} finally {
isLoading.value = false
}
}
return { query, results, isLoading, search }
}
Every function exported from this file follows the use convention, making the intent crystal clear.
Share across pages and components
One of composables' greatest strengths is reusability without prop drilling or event emission chains. Let's walk through a real scenario: sharing authentication state across a dashboard, sidebar, and user profile page.
// composables/useAuth.ts
import { ref, computed } from 'vue'
export const useAuth = () => {
const user = ref(null)
const isAuthenticated = computed(() => !!user.value)
const token = ref('')
const login = async (email: string, password: string) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await res.json()
user.value = data.user
token.value = data.token
}
const logout = () => {
user.value = null
token.value = ''
}
return { user, isAuthenticated, token, login, logout }
}
Now use it anywhere:
// pages/dashboard.vue
<script setup lang="ts">
const { user, isAuthenticated, logout } = useAuth()
// Direct access, no prop passing
</script>
<template>
<div v-if="isAuthenticated">
<p>Welcome, {{ user.name }}</p>
<button @click="logout">Logout</button>
</div>
</template>
// components/Sidebar.vue
<script setup lang="ts">
const { user } = useAuth()
</script>
<template>
<aside>
<img :src="user?.avatar" />
</aside>
</template>
Both the page and component access the same composable instance. However, note: composables are called fresh in each component that uses them. If you need true global state—data shared and cached across all instances—you'll want Pinia instead. Composables are perfect for logic patterns (form handling, data fetching, timers) that each component instance manages independently.
Keep them SSR-safe
This is where many teams hit snags. Nuxt runs your code on the server during SSR. If your composable touches the DOM, accesses localStorage, or relies on client-only APIs, it will crash on the server.
Here's an unsafe composable:
// composables/useBadTheme.ts (UNSAFE for SSR)
export const useBadTheme = () => {
const theme = ref(localStorage.getItem('theme') || 'light') // ERROR on server
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
localStorage.setItem('theme', theme.value) // ERROR on server
}
return { theme, toggleTheme }
}
On the server, localStorage doesn't exist. Use Nuxt's useClientOnly or the #app utilities to protect client-specific logic:
// composables/useTheme.ts (SSR-safe)
import { ref, watchEffect } from 'vue'
export const useTheme = () => {
const theme = ref('light')
// Only runs on client
if (process.client) {
theme.value = localStorage.getItem('theme') || 'light'
}
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
if (process.client) {
localStorage.setItem('theme', theme.value)
}
}
return { theme, toggleTheme }
}
Alternatively, use the useClientOnly component to wrap client-only logic:
// composables/useLocalStorage.ts
import { useClientOnly } from '#app'
export const useLocalStorage = (key: string, defaultValue: string) => {
return useClientOnly(() => {
const value = ref(localStorage.getItem(key) || defaultValue)
const set = (newValue: string) => {
value.value = newValue
localStorage.setItem(key, newValue)
}
return { value, set }
})
}
For async operations that fetch data, always use useAsyncData or useFetch instead of raw fetch calls. These handle SSR hydration correctly:
// composables/useProduct.ts (correct for SSR)
import { useAsyncData } from '#app'
export const useProduct = (id: string) => {
const { data: product, error } = useAsyncData(`product-${id}`, () =>
fetch(`/api/products/${id}`).then(r => r.json())
)
return { product, error }
}
Common mistakes
- Mixing global state with instance state: A composable is called fresh per component instance. If you want truly shared state, use Pinia or a ref stored outside the composable. Don't assume all instances share the same data.
- Ignoring SSR in client-side APIs: Check
process.clientor use server utilities before callingwindow,document, or browser storage. This is the #1 source of SSR crashes. - Over-nesting subdirectories: Deeply nested composables (e.g.,
composables/features/auth/helpers/validation/useEmailValidator.ts) make auto-imports harder to discover. Keep the folder structure flat or one level deep. - Not handling loading and error states: Real composables need proper error handling. Return loading flags and error refs so components can show spinners and error messages.
- Naming collisions: If you have two files exporting functions with the same name, the second one wins. Always check your composables exports are unique, or alias them in your components.
Key takeaways
- Composables in the
composables/folder are auto-imported by Nuxt—no manual import needed. This saves boilerplate and keeps code clean. - Always prefix composable names with
useto signal they contain stateful, reactive logic. This improves readability and helps linters. - Share composables across pages and components to avoid prop drilling and duplication. Each component instance gets its own call, so for true global state, reach for Pinia.
- Keep composables SSR-safe by checking
process.client, avoiding browser APIs on the server, and usinguseAsyncDatafor data fetching. - Test composables in isolation before using them widely. Return clear loading and error states so consumers can handle edge cases.






Comments
Loading comments…