
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
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.
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.
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.
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>
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 sharinguseState() for state that should persist across SSR hydration or be shared globallyuseAsyncData() for async data that should be fetched once on the serverNuxt 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.
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.
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.
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 };
}
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.
useState() instead of ref() when state needs to survive SSR hydration or be shared across componentsuseAsyncData() for any async data fetching to avoid double-fetching between server and clientuse and name them after what they manageMaybeRef and use toRef/toValue for flexibilityuseAsyncData or explicit function callsWe 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!
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.

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
Apr 22, 2026

How the Laravel Service Container Actually Works Under the Hood
Go beyond the āmagicā of Laravelās dependency injection. Learn how the service container works under the hood and why itās essential for building clean, testable, and maintainable applications.
Steve McDougall
Apr 16, 2026

JavaScript Mistakes That Quietly Destroy Production Apps
Some JavaScript mistakes donāt crash your app, they slowly degrade performance, reliability, and user trust. Here are the ones that cost the most in production.
Martin Ferret
Apr 14, 2026
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.
