Frontend Composables

Nuxt Auto API provides TanStack Query-powered composables for seamless data fetching, caching, and mutations on the frontend.

Frontend Composables

Nuxt Auto API provides TanStack Query-powered composables for seamless data fetching, caching, and mutations on the frontend.

Table of Contents

Installation

The composables are automatically available when you install @websideproject/nuxt-auto-api. They use TanStack Query (Vue Query) under the hood.

npm install @websideproject/nuxt-auto-api

Query Composables

useAutoApiList

Fetch a list of resources with automatic caching and revalidation.

Basic Usage

<script setup lang="ts">
const { data, isLoading, error, refetch } = useAutoApiList('posts')
</script>

<template>
  <div>
    <div v-if="isLoading">Loading...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <div v-else-if="data">
      <article v-for="post in data.data" :key="post.id">
        <h2>{{ post.title }}</h2>
      </article>
    </div>
  </div>
</template>

With Filters

<script setup lang="ts">
const route = useRoute()

const { data } = useAutoApiList('posts', {
  filter: {
    status: route.query.status || 'published',
    userId: route.query.author
  },
  sort: '-createdAt',
  limit: 10
})
</script>

With Relations

<script setup lang="ts">
const { data } = useAutoApiList('posts', {
  include: 'author,comments',
  filter: { published: true }
})
</script>

<template>
  <article v-for="post in data?.data" :key="post.id">
    <h2>{{ post.title }}</h2>
    <p>By {{ post.author.name }}</p>
    <p>{{ post.comments.length }} comments</p>
  </article>
</template>

API Reference

function useAutoApiList<T>(
  resource: MaybeRef<string>,
  params?: MaybeRef<ListQueryParams>,
  options?: UseQueryOptions
)

interface ListQueryParams {
  filter?: Record<string, any>  // Filtering
  sort?: string | string[]      // Sorting (-field for desc)
  page?: number                 // Page number
  limit?: number                // Items per page
  include?: string | string[]   // Relations to include
  fields?: string | string[]    // Select specific fields
}

Return Value

{
  data: Ref<ListResponse<T>> // { data: T[], meta: { ... } }
  isLoading: Ref<boolean>
  error: Ref<Error | null>
  refetch: () => void
  // ... other TanStack Query properties
}

useAutoApiGet

Fetch a single resource by ID.

Basic Usage

<script setup lang="ts">
const route = useRoute()
const postId = route.params.id

const { data, isLoading } = useAutoApiGet('posts', postId)
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <article v-else-if="data">
    <h1>{{ data.data.title }}</h1>
    <p>{{ data.data.content }}</p>
  </article>
</template>

With Relations

<script setup lang="ts">
const { data } = useAutoApiGet('posts', postId, {
  include: 'author,comments'
})
</script>

<template>
  <article v-if="data">
    <h1>{{ data.data.title }}</h1>
    <p>By {{ data.data.author.name }}</p>

    <div v-for="comment in data.data.comments" :key="comment.id">
      {{ comment.content }}
    </div>
  </article>
</template>

API Reference

function useAutoApiGet<T>(
  resource: MaybeRef<string>,
  id: MaybeRef<string | number>,
  params?: MaybeRef<{
    include?: string | string[]
    fields?: string | string[]
  }>,
  options?: UseQueryOptions
)

useAutoApiInfinite

Infinite scroll pagination with cursor-based loading.

Basic Usage

<script setup lang="ts">
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useAutoApiInfinite('posts', {
  limit: 20,
  sort: '-createdAt'
})

const allPosts = computed(() => {
  return data.value?.pages.flatMap(page => page.data) ?? []
})
</script>

<template>
  <div>
    <article v-for="post in allPosts" :key="post.id">
      <h2>{{ post.title }}</h2>
    </article>

    <button
      v-if="hasNextPage"
      @click="fetchNextPage"
      :disabled="isFetchingNextPage"
    >
      {{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
    </button>
  </div>
</template>

With Intersection Observer

<script setup lang="ts">
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useAutoApiInfinite('posts', {
  limit: 20
})

const allPosts = computed(() => data.value?.pages.flatMap(page => page.data) ?? [])

const loadMoreRef = ref<HTMLElement>()

onMounted(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
        fetchNextPage()
      }
    },
    { threshold: 0.1 }
  )

  if (loadMoreRef.value) {
    observer.observe(loadMoreRef.value)
  }

  onUnmounted(() => observer.disconnect())
})
</script>

<template>
  <div>
    <article v-for="post in allPosts" :key="post.id">
      <h2>{{ post.title }}</h2>
    </article>

    <div ref="loadMoreRef" v-if="hasNextPage">
      <div v-if="isFetchingNextPage">Loading more...</div>
    </div>
  </div>
</template>

Mutation Composables

useAutoApiMutation

Unified mutation API that automatically dispatches to the correct operation (create, update, or delete). This is the recommended approach for most use cases as it provides a cleaner, more consistent API.

Basic Usage

<script setup lang="ts">
const toast = useToast()

// Create mutation
const { mutateAsync: createPost } = useAutoApiMutation('posts', 'create', {
  toast: {
    success: { title: 'Post created!' },
    error: { title: 'Failed to create post' }
  }
})

// Update mutation
const { mutateAsync: updatePost } = useAutoApiMutation('posts', 'update', {
  toast: {
    success: { title: 'Post updated!' },
    error: { title: 'Failed to update post' }
  }
})

// Delete mutation
const { mutateAsync: deletePost } = useAutoApiMutation('posts', 'delete', {
  toast: {
    success: { title: 'Post deleted!' },
    error: { title: 'Failed to delete post' }
  }
})

// Usage
const handleCreate = async () => {
  await createPost({
    title: 'New Post',
    content: 'Content here'
  })
}

const handleUpdate = async (postId: number) => {
  await updatePost({
    id: postId,
    data: {
      title: 'Updated Title'
    }
  })
}

const handleDelete = async (postId: number) => {
  await deletePost(postId)
}
</script>

With Toast Notifications

The mutation composables integrate with Nuxt UI's toast system:

<script setup lang="ts">
const { mutateAsync: createPost, isPending } = useAutoApiMutation('posts', 'create', {
  toast: {
    success: {
      title: 'Success!',
      description: 'Your post has been created'
    },
    error: {
      title: 'Error',
      description: 'Failed to create post'
    }
  }
})

const handleSubmit = async () => {
  try {
    await createPost(formData.value)
    // Toast automatically shown on success
    navigateTo('/posts')
  } catch (err) {
    // Toast automatically shown on error
    console.error(err)
  }
}
</script>

API Reference

function useAutoApiMutation<T, TBody>(
  resource: MaybeRef<string>,
  action: 'create' | 'update' | 'delete',
  options?: {
    toast?: {
      success?: { title: string; description?: string }
      error?: { title: string; description?: string }
    }
    onSuccess?: (data: any, variables: any, context: any) => void
    onError?: (error: Error, variables: any, context: any) => void
    // ... other TanStack Query mutation options
  }
)

Return Value

{
  mutate: (variables: TVariables) => void
  mutateAsync: (variables: TVariables) => Promise<TData>
  isPending: Ref<boolean>
  isSuccess: Ref<boolean>
  isError: Ref<boolean>
  error: Ref<Error | null>
  data: Ref<TData | undefined>
  reset: () => void
  // ... other TanStack Query mutation properties
}

useAutoApiCreate

Create a new resource with automatic cache invalidation.

Note: Consider using the unified useAutoApiMutation('resource', 'create') API for a more consistent interface. This section documents the direct API.

Basic Usage

<script setup lang="ts">
const router = useRouter()

const { mutate, isPending, error } = useAutoApiCreate('posts', {
  onSuccess: (response) => {
    // List cache automatically invalidated
    router.push(`/posts/${response.data.id}`)
  }
})

const form = ref({
  title: '',
  content: '',
  userId: 1
})

const handleSubmit = () => {
  mutate(form.value)
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.title" placeholder="Title" required />
    <textarea v-model="form.content" placeholder="Content" />

    <button :disabled="isPending">
      {{ isPending ? 'Creating...' : 'Create Post' }}
    </button>

    <div v-if="error" class="error">{{ error.message }}</div>
  </form>
</template>

API Reference

function useAutoApiCreate<T, TBody>(
  resource: MaybeRef<string>,
  options?: UseMutationOptions<GetResponse<T>, Error, TBody>
)

useAutoApiUpdate

Update an existing resource with cache invalidation.

Note: Consider using the unified useAutoApiMutation('resource', 'update') API for a more consistent interface. This section documents the direct API.

Basic Usage

<script setup lang="ts">
const route = useRoute()
const router = useRouter()

const postId = route.params.id

// Fetch existing post
const { data: post } = useAutoApiGet('posts', postId)

// Update mutation
const { mutate, isPending } = useAutoApiUpdate('posts', {
  onSuccess: () => {
    router.push(`/posts/${postId}`)
  }
})

const form = ref<any>({})

watch(() => post.value?.data, (data) => {
  if (data) {
    form.value = { ...data }
  }
}, { immediate: true })

const handleSubmit = () => {
  mutate({
    id: postId,
    title: form.value.title,
    content: form.value.content
  })
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.title" />
    <textarea v-model="form.content" />

    <button :disabled="isPending">
      {{ isPending ? 'Saving...' : 'Save Changes' }}
    </button>
  </form>
</template>

useAutoApiDelete

Delete a resource with cache cleanup.

Note: Consider using the unified useAutoApiMutation('resource', 'delete') API for a more consistent interface. This section documents the direct API.

Basic Usage

<script setup lang="ts">
const router = useRouter()

const { mutate, isPending } = useAutoApiDelete('posts', {
  onSuccess: () => {
    router.push('/posts')
  }
})

const handleDelete = (postId: number) => {
  if (confirm('Are you sure?')) {
    mutate(postId)
  }
}
</script>

<template>
  <button
    @click="handleDelete(post.id)"
    :disabled="isPending"
  >
    {{ isPending ? 'Deleting...' : 'Delete' }}
  </button>
</template>

Advanced Patterns

Optimistic Updates

Update the UI immediately before the server responds, then rollback on error.

<script setup lang="ts">
const queryClient = useQueryClient()

const { mutate } = useAutoApiUpdate('posts', {
  onMutate: async (variables) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({
      queryKey: ['autoapi', 'posts', 'get', variables.id]
    })

    // Snapshot previous value
    const previousData = queryClient.getQueryData(['autoapi', 'posts', 'get', variables.id])

    // Optimistically update
    queryClient.setQueryData(['autoapi', 'posts', 'get', variables.id], (old: any) => ({
      data: { ...old.data, ...variables }
    }))

    return { previousData }
  },
  onError: (err, variables, context) => {
    // Rollback on error
    if (context?.previousData) {
      queryClient.setQueryData(
        ['autoapi', 'posts', 'get', variables.id],
        context.previousData
      )
    }
  }
})
</script>

Or use the helper:

<script setup lang="ts">
const { mutate } = useAutoApiUpdate('posts', {
  onMutate: async (variables) => {
    return useAutoApiOptimisticUpdate('posts', variables.id, variables)
  },
  onError: (err, variables, context) => {
    if (context) {
      queryClient.setQueryData(context.queryKey, context.previousData)
    }
  }
})
</script>

Infinite Scroll

<script setup lang="ts">
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useAutoApiInfinite('posts', {
  limit: 20,
  filter: { published: true },
  sort: '-createdAt'
})

const allPosts = computed(() => {
  return data.value?.pages.flatMap(page => page.data) ?? []
})

const handleScroll = () => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement

  if (scrollTop + clientHeight >= scrollHeight - 100) {
    if (hasNextPage.value && !isFetchingNextPage.value) {
      fetchNextPage()
    }
  }
}

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
  onUnmounted(() => window.removeEventListener('scroll', handleScroll))
})
</script>

Server-Side Rendering

Composables work seamlessly with Nuxt's SSR.

<script setup lang="ts">
// Data will be prefetched on server and hydrated on client
const { data, isLoading } = useAutoApiList('posts', {
  filter: { published: true },
  sort: '-createdAt',
  limit: 10
}, {
  staleTime: 1000 * 60 * 5 // 5 minutes
})
</script>

<template>
  <!-- Posts rendered on server, cached on client -->
  <div v-if="data">
    <article v-for="post in data.data" :key="post.id">
      <h2>{{ post.title }}</h2>
    </article>
  </div>
</template>

Error Handling

All composables return error states for handling failures.

<script setup lang="ts">
const { data, error, isError } = useAutoApiList('posts')

const { mutate, error: createError } = useAutoApiCreate('posts', {
  onError: (error) => {
    console.error('Failed to create post:', error)
    // Show toast notification
  }
})
</script>

<template>
  <div v-if="isError">
    <p>Failed to load posts: {{ error.message }}</p>
    <button @click="refetch">Retry</button>
  </div>

  <form @submit.prevent="handleSubmit">
    <!-- ... -->
    <p v-if="createError" class="error">
      {{ createError.message }}
    </p>
  </form>
</template>

TypeScript Support

All composables are fully typed.

<script setup lang="ts">
interface Post {
  id: number
  title: string
  content: string
  userId: number
  author?: User
}

interface User {
  id: number
  name: string
  email: string
}

// Typed response
const { data } = useAutoApiList<Post>('posts', {
  include: 'author'
})

// Type-safe access
const firstPost = computed(() => data.value?.data[0])
// firstPost.value.title ✅
// firstPost.value.author.name ✅
// firstPost.value.nonexistent ❌ Type error

// Typed mutations
const { mutate } = useAutoApiCreate<Post, Partial<Post>>('posts')

mutate({
  title: 'New Post',
  content: 'Content',
  userId: 1
}) // ✅

mutate({
  invalid: 'field'
}) // ❌ Type error
</script>

Cache Configuration

Customize TanStack Query behavior globally.

// plugins/query-config.ts
export default defineNuxtPlugin(() => {
  const queryClient = useNuxtApp().$queryClient

  queryClient.setDefaultOptions({
    queries: {
      staleTime: 1000 * 60 * 10, // 10 minutes
      gcTime: 1000 * 60 * 30,    // 30 minutes
      retry: 3
    }
  })
})

Best Practices

  1. Use the unified mutation API - Prefer useAutoApiMutation over individual mutation composables for consistency
  2. Use reactive params - Pass refs to make queries reactive
  3. Enable/disable queries - Use enabled option for conditional fetching
  4. Prefetch data - Use prefetchQuery for better UX
  5. Invalidate strategically - Let mutations auto-invalidate related queries
  6. Handle loading states - Show skeletons or spinners
  7. Type your data - Use TypeScript for better DX
  8. Cache wisely - Set appropriate staleTime and gcTime
  9. Optimize re-renders - Use computed for derived state
  10. Use toast notifications - Enable automatic toast notifications for mutations to improve UX

Resources

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.