Composable Best Practices in Nuxt

Composable Best Practices in Nuxt

Learn how to use composables in Nuxt effectively, avoid common SSR and state pitfalls, and build production-ready patterns in this practical 15-minute guide.

Reza Baar

Reza Baar

April 22, 2026

Composables are the primary way to share logic across components in Nuxt. But there are a few Nuxt-specific pitfalls that can trip you up, especially around SSR and shared state. In this post (about 15 mins), we'll walk through the patterns that work well and the ones that don't, starting from inline logic and building toward production ready composables.

The Problem: Duplicated Logic

Let's say you have two components that both need to fetch and manage a list of products. Without composables, you end up duplicating the same logic:

      <!-- pages/products.vue -->
<script setup lang="ts">
const products = ref<Product[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);

async function fetchProducts() {
  loading.value = true;
  try {
    const data = await $fetch('/api/products');
    products.value = data;
  } catch (e) {
    error.value = 'Failed to load products';
  } finally {
    loading.value = false;
  }
}

onMounted(() => fetchProducts());
</script>

    

And then you write the same thing again in another component. This is where composables come in.

Extracting a Basic Composable

Let's pull that logic into a function. In Nuxt, composables live in the composables/ directory and are auto-imported:

      // composables/useProducts.ts
export function useProducts() {
  const products = ref<Product[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  async function fetchProducts() {
    loading.value = true;
    try {
      const data = await $fetch('/api/products');
      products.value = data;
    } catch (e) {
      error.value = 'Failed to load products';
    } finally {
      loading.value = false;
    }
  }

  return { products, loading, error, fetchProducts };
}

    

Now any component can use it:

      <!-- pages/products.vue -->
<script setup lang="ts">
const { products, loading, error, fetchProducts } = useProducts();

onMounted(() => fetchProducts());
</script>

<template>
  <div>
    <p v-if="loading">Loading...</p>
    <p v-else-if="error">{{ error }}</p>
    <ul v-else>
      <li v-for="product in products" :key="product.id">
        {{ product.name }}
      </li>
    </ul>
  </div>
</template>

    

This works, but it has a problem. Each component that calls useProducts() gets its own copy of the state. That might be what you want sometimes. But it also doesn't take advantage of Nuxt's SSR capabilities.

Making It SSR-Safe with useAsyncData

In Nuxt, if you fetch data on the server and then the client hydrates, you want to avoid fetching again on the client. That's what useAsyncData handles. Let's update the composable:

      // composables/useProducts.ts
export function useProducts() {
  const {
    data: products,
    status,
    error,
    refresh,
  } = useAsyncData('products', () => $fetch('/api/products'));

  return { products, status, error, refresh };
}

    

This is a big improvement. useAsyncData fetches on the server during SSR, serializes the result into the page payload, and hydrates it on the client without refetching. The status ref gives you 'idle' | 'pending' | 'success' | 'error' instead of managing a manual loading boolean.

The component becomes even simpler:

      <!-- pages/products.vue -->
<script setup lang="ts">
const { products, status, error } = useProducts();
</script>

<template>
  <div>
    <p v-if="status === 'pending'">Loading...</p>
    <p v-else-if="error">{{ error.message }}</p>
    <ul v-else>
      <li v-for="product in products" :key="product.id">
        {{ product.name }}
      </li>
    </ul>
  </div>
</template>

    

The useState vs ref Decision

Here's one of the most common mistakes in Nuxt composables. If you use ref() for state that needs to survive SSR hydration or be shared across components, it won't work the way you expect.

ref() creates a new reactive reference every time the composable is called. On the server, state created with ref() is not serialized into the page payload. This means the client starts with empty state and has to refetch.

useState() is Nuxt's SSR-safe alternative:

      // composables/useCounter.ts

// This breaks across SSR
export function useCounterBroken() {
  const count = ref(0);
  function increment() {
    count.value++;
  }
  return { count, increment };
}

// This works across SSR
export function useCounter() {
  const count = useState('counter', () => 0);
  function increment() {
    count.value++;
  }
  return { count, increment };
}

    

useState takes a unique key and an initializer function. The key ensures that the state is serialized during SSR and properly hydrated on the client. It also means every component that calls useCounter() shares the same state instance.

When to use which:

  • ref() for local component state that doesn't need SSR serialization or sharing
  • useState() for state that should persist across SSR hydration or be shared globally
  • useAsyncData() for async data that should be fetched once on the server

Composable Naming Conventions

Nuxt auto-imports composables from the composables/ directory. Stick to these conventions to keep things predictable:

      composables/
  useProducts.ts      -> useProducts()
  useAuth.ts          -> useAuth()
  useCart.ts          -> useCart()
  utils/
    useFormatDate.ts  -> useFormatDate()

    

Always prefix with use. This isn't just convention. It tells Vue's reactivity system (and linting tools) that this function may contain reactive state and should follow the rules of composition.

Avoid generic names like useData or useStore. Be specific about what the composable manages.

Handling Parameters

Composables often need input. Accept parameters as refs or plain values, and use toRef or toValue to handle both:

      // composables/useProduct.ts
export function useProduct(id: MaybeRef<string>) {
  const productId = toRef(id);

  const { data: product, status } = useAsyncData(
    `product-${toValue(id)}`,
    () => $fetch(`/api/products/${productId.value}`),
    { watch: [productId] }
  );

  return { product, status };
}

    

This way the composable works with both static and reactive values:

      <script setup lang="ts">
// Static
const { product } = useProduct('abc-123');

// Reactive
const selectedId = ref('abc-123');
const { product } = useProduct(selectedId);
</script>

    

Passing { watch: [productId] } to useAsyncData ensures the data refetches when the ID changes.

Composing Composables

Composables can call other composables. This is where the real power comes in. Let's build a cart composable that depends on the auth composable:

      // composables/useAuth.ts
export function useAuth() {
  const user = useState<User | null>('auth-user', () => null);
  const isLoggedIn = computed(() => user.value !== null);

  async function login(credentials: { email: string; password: string }) {
    user.value = await $fetch('/api/auth/login', {
      method: 'POST',
      body: credentials,
    });
  }

  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' });
    user.value = null;
  }

  return { user, isLoggedIn, login, logout };
}

    
      // composables/useCart.ts
export function useCart() {
  const { user, isLoggedIn } = useAuth();
  const items = useState<CartItem[]>('cart-items', () => []);

  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  async function addItem(productId: string) {
    if (!isLoggedIn.value) {
      throw new Error('Must be logged in to add items');
    }

    const item = await $fetch('/api/cart/add', {
      method: 'POST',
      body: { productId, userId: user.value!.id },
    });

    items.value.push(item);
  }

  return { items, total, addItem };
}

    

useCart calls useAuth internally. Because both use useState, they share state across the app. The cart logic depends on auth state without the component needing to wire them together.

Avoid Top-Level Side Effects

One pattern to avoid: running side effects at the top level of a composable outside of a lifecycle hook or explicit function call.

      // Don't do this
export function useBadExample() {
  const data = ref(null);

  // This runs every time the composable is called
  $fetch('/api/something').then((res) => {
    data.value = res;
  });

  return { data };
}

    

This fires a request on every component that calls useBadExample(), including during SSR where it may not resolve before the response is sent. Use useAsyncData or wrap side effects in explicit functions:

      // Do this instead
export function useGoodExample() {
  const { data } = useAsyncData('something', () =>
    $fetch('/api/something')
  );

  return { data };
}

    

Returning Consistent Shapes

Always return an object, not individual refs. This keeps destructuring predictable and makes it easy to add new return values without breaking existing consumers:

      // Consistent return shape
export function useProducts() {
  const { data, status, error, refresh } = useAsyncData(
    'products',
    () => $fetch('/api/products')
  );

  const isEmpty = computed(() => !data.value?.length);

  return {
    products: data,
    status,
    error,
    isEmpty,
    refresh,
  };
}

    

Renaming data to products in the return object gives consumers a clearer API without affecting the internal implementation.

Key Takeaways

  • Use useState() instead of ref() when state needs to survive SSR hydration or be shared across components
  • Use useAsyncData() for any async data fetching to avoid double-fetching between server and client
  • Prefix composables with use and name them after what they manage
  • Accept parameters as MaybeRef and use toRef/toValue for flexibility
  • Composables can call other composables to build layered logic
  • Avoid top-level side effects. Use useAsyncData or explicit function calls
  • Always return an object with named properties

Conclusion

We walked through the progression from duplicated inline logic to SSR-safe, composable Nuxt patterns. The key thing to remember is that Nuxt has specific tools (useState, useAsyncData) that replace the vanilla Vue patterns (ref, manual fetching) when you're working in an SSR context.

I hope this post has been helpful. Please share questions or comments. Happy coding!

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)