Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 087 / 100

Nuxt composables folder: real-world lessons and examples

Reusable logic with auto imports.

Expona Admin8 min read

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:

typescript
// 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:

typescript
// 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:

typescript
// 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.

typescript
// 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:

typescript
// 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>
typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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.client or use server utilities before calling window, 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 use to 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 using useAsyncData for data fetching.
  • Test composables in isolation before using them widely. Return clear loading and error states so consumers can handle edge cases.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles