What are composables in Nuxt?
Composables in Nuxt are auto-imported Vue Composition API functions that live in the composables/ directory and encapsulate reactive state together with the logic that operates on it.
Theory
TL;DR
- Think of a composable like a shared toolkit on a shelf: call
useCounter()anywhere in the app and it brings its own state and methods, no import statement needed. - Main difference from plain Vue composables: Nuxt auto-imports them at build time, and
useStategives SSR-safe shared state that survives page navigation. - Use composables when logic repeats across 3+ components. For a single component, inline
ref/computedis enough. - Nuxt scans
composables/at build and adds every named export to.nuxt/imports.d.ts.
Quick example
// composables/useCounter.ts
export const useCounter = () => {
const count = useState('counter', () => 0) // SSR-safe, keyed shared state
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}<!-- pages/index.vue — no import statement anywhere -->
<script setup>
const { count, increment } = useCounter()
</script>
<template>
<div>Count: {{ count }} <button @click="increment()">+</button></div>
</template>useState('counter', ...) creates a reactive ref keyed to the string 'counter'. On the server, that key maps to a per-request store so state never leaks between users. On the client, the value persists across navigation. Every component that calls useCounter() works with the same reactive ref.
Key difference from plain Vue composables
Plain Vue composables are functions you write and import manually. Nuxt adds two things on top: automatic imports (the Vite plugin injects them into every component at build time) and useState, which bridges the SSR/client gap. A regular ref inside a composable is local to the component that called it. useState with a unique string key is shared across all components and does not reset on client-side navigation.
When to use
- Repeated fetch logic across multiple pages: a composable instead of copy-pasting
$fetchcalls. - Shared app state like auth session or color theme: a
useStatecomposable before reaching for Pinia. - Component-specific logic: keep it inline. A 10-line composable for a single
computedadds files without adding value. - Plugins and middleware that need reactive state: composables work there too.
Pinia makes sense for normalized global state written from many unrelated parts of the app. For self-contained logic, a composable is simpler and sufficient.
How Nuxt's auto-import works
Nuxt's Vite plugin scans composables/ at build time and generates an auto-imports map in .nuxt/imports.d.ts. Every file in that directory and every named export inside it becomes globally available across components and pages. TypeScript picks up the types automatically, editor autocomplete works, and there is not a single import statement in component files.
During SSR, useState keys into a Map scoped to the current request. On the client, Vue's reactivity proxies make sure any write to a ref triggers re-renders in every component that reads it. No prop drilling required, no event bus.
Common mistakes
1. Forgetting .value on useState
// Wrong
const count = useState('count', () => 0)
setTimeout(() => count = 5, 1000) // ❌ replaces the ref object, not its value
// Correct
setTimeout(() => count.value = 5, 1000) // ✅useState returns a ref. Direct assignment replaces the ref object itself, so reactivity breaks and nothing re-renders.
2. Treating composables like singletons
// Wrong
export const useConfig = () => {
const config = useState('config', () => fetchConfig()) // ❌ factory runs once per key, but async issues arise
return { config }
}The factory in useState runs only once per key, but putting async work inside it directly causes unpredictable behavior because the factory is synchronous. Use useLazyAsyncData or fetch inside onMounted instead.
3. Accessing browser APIs at the top level
// Wrong
export const useStorage = () => {
const data = ref(localStorage.getItem('key')) // ❌ localStorage does not exist on the server
return { data }
}
// Correct
export const useStorage = () => {
const data = ref(null)
if (process.client) {
data.value = localStorage.getItem('key')
}
return { data }
}SSR renders the component on the server first. localStorage is undefined there. Server renders null, client renders a value, you get a hydration mismatch error. Guard with process.client or align initial values with useState.
4. Skipping event listener cleanup
// Wrong
export const useWindowSize = () => {
const width = ref(window.innerWidth)
window.addEventListener('resize', () => width.value = window.innerWidth)
// ❌ new listener added on every navigation
return { width }
}
// Correct
export const useWindowSize = () => {
const width = ref(process.client ? window.innerWidth : 0)
if (process.client) {
const update = () => width.value = window.innerWidth
window.addEventListener('resize', update)
onBeforeUnmount(() => window.removeEventListener('resize', update))
// ✅ removed when component unmounts
}
return { width }
}Without cleanup, five page visits stack five listeners firing on every resize. This one shows up in production before it shows up in code review.
Real-world usage
- Nuxt Auth module:
useUser()shares the session object across layouts, pages, and middleware. - Nuxt UI:
useTabs()manages tab state across nested components without lifting state. - Nuxt Content v2:
useAsyncData()fetches markdown content with cache keys. - Supabase + Nuxt:
useSupabaseUser()returns reactive auth state that updates on login and logout.
Follow-up questions
Q: How does useState differ from ref in a composable?
A: ref is local to the component that called the composable. useState with a string key is shared across all components and persists across client-side navigation. On the server each request gets its own isolated key-value store, so state never leaks between users.
Q: What happens if two composables write to the same useState key?
A: Last write wins. Both composables hold a reference to the same reactive ref, so any write from either one propagates immediately to every component reading it.
Q: How does the auto-import mechanism work exactly?
A: Nuxt scans composables/ at build time and generates .nuxt/imports.d.ts. The Vite plugin injects the imports into every component file automatically. You never write the import yourself, but it is there after the build step.
Q: How do you make a composable run only on the client?
A: Wrap browser-specific code in if (process.client) { ... }. Alternatively, call the composable inside onMounted() so it runs after hydration. For async data, useLazyAsyncData defers execution to the client side.
Q (senior level): You have a hydration mismatch error coming from a composable. How do you debug it?
A: First check whether the composable accesses client-only APIs like localStorage, window, or document at the top level outside any guard. Then verify that useState keys are consistent between server and client renders. Look at the Nitro storage layer if the composable uses server-side persistence. Adding process.client guards or replacing ref with useState usually aligns the initial values on both sides.
Examples
Basic: shared counter across pages
// composables/useCounter.ts
export const useCounter = () => {
const count = useState('counter', () => 0)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}<!-- pages/home.vue and pages/about.vue both call useCounter() -->
<script setup>
const { count, increment } = useCounter()
</script>
<template>
<button @click="increment">Count: {{ count }}</button>
</template>Navigate from /home to /about. The counter keeps its value because useState('counter', ...) uses the same key on both pages. Both components share one reactive ref without any store setup.
Intermediate: user profile fetch in a dashboard
// composables/useUserProfile.ts
export const useUserProfile = () => {
const profile = ref(null)
const loading = ref(false)
const error = ref('')
const fetchProfile = async (userId: string) => {
loading.value = true
error.value = ''
try {
const data = await $fetch(`/api/users/${userId}`)
profile.value = data
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
return { profile, loading, error, fetchProfile }
}<!-- components/Dashboard.vue -->
<script setup>
const route = useRoute()
const { profile, loading, error, fetchProfile } = useUserProfile()
onMounted(() => fetchProfile(route.params.id as string))
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<UserCard v-else :user="profile" />
</template>Any page that needs a user profile calls useUserProfile() and gets the same fetch/loading/error pattern. Dashboard.vue, ProfilePage.vue, and AdminView.vue all reuse the same composable instead of duplicating the fetch logic.
Advanced: window resize listener with proper cleanup
// composables/useWindowSize.ts
export const useWindowSize = () => {
const width = ref(process.client ? window.innerWidth : 0)
if (process.client) {
const update = () => { width.value = window.innerWidth }
window.addEventListener('resize', update)
onBeforeUnmount(() => window.removeEventListener('resize', update))
}
return { width }
}<script setup>
const { width } = useWindowSize()
</script>
<template>
<div>Viewport: {{ width }}px</div>
</template>The process.client guard prevents window access during SSR. onBeforeUnmount removes the listener when the component unmounts, so navigating between pages never stacks up extra listeners. The version without cleanup is the classic interview trap question about memory leaks in Nuxt composables.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.