$api

View source
The enhanced $fetch instance created by the api.factory plugin, supporting auth injection, data unwrapping, business code checking and multi-endpoint switching.

Introduction

$api is a global $fetch instance injected by a Nuxt plugin, with a full built-in interceptor chain (auth injection, business status code checking, data unwrapping, Toast notifications). It is the underlying engine for all API composables (useApiFetch, useLazyApiFetch, etc.).

Usage

const { $api } = useNuxtApp()

// GET request (data is automatically unwrapped)
const users = await $api<User[]>('/users')

// POST request
const user = await $api<User>('/users', {
  method: 'POST',
  body: { name: 'test', email: 'test@example.com' }
})

// PUT request
const updated = await $api<User>(`/users/${id}`, {
  method: 'PUT',
  body: { name: 'new-name' }
})

// DELETE request
await $api(`/users/${id}`, { method: 'DELETE' })
Data returned by $api has already been automatically unwrapped by the interceptor (the dataKey field is extracted); the generic T directly maps to the business data type.

Multi-Endpoint Switching

Use $api.use(endpoint) to switch to a specific endpoint:

const { $api } = useNuxtApp()

// Use the default endpoint
const users = await $api('/users')

// Switch to the admin endpoint
const adminUsers = await $api.use('admin')('/users')

// Save an endpoint reference to avoid repeated use() calls
const adminApi = $api.use('admin')
const config = await adminApi('/config')
const stats = await adminApi('/stats')

Endpoint instances are internally cached, so multiple calls to $api.use('admin') return the same instance.

Switch and Compare baseURL

$api uses the baseURL of the default endpoint; calling $api.use(name) switches to the named endpoint's baseURL. Select a different endpoint in the dropdown to observe the baseURL and response change accordingly:

baseURL: /api

$api 默认走 default 端点的 baseURL,$api.use(name) 切换到别名端点后请求改用对应 baseURL。

<script setup lang="ts">
const props = defineProps<{
  endpoint: 'default' | 'v2'
}>()

const { $api } = useNuxtApp()
const publicConfig = useRuntimeConfig().public.movkApi

const data = ref<unknown>(null)
const pending = ref(false)
const error = ref<string | null>(null)

const baseURL = computed(() => publicConfig.endpoints?.[props.endpoint]?.baseURL)

async function load() {
  pending.value = true
  error.value = null
  try {
    data.value = await $api.use(props.endpoint)('/profile')
  }
  catch (err) {
    error.value = err instanceof Error ? err.message : String(err)
  }
  finally {
    pending.value = false
  }
}

watch(() => props.endpoint, load, { immediate: true })
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2 flex-wrap">
      <UBadge :color="endpoint === 'v2' ? 'secondary' : 'neutral'" variant="subtle">
        baseURL: {{ baseURL }}
      </UBadge>
      <UButton size="sm" variant="outline" icon="i-lucide-refresh-cw" :loading="pending" @click="load">
        刷新
      </UButton>
    </div>
    <p class="text-xs text-muted">
      $api 默认走 default 端点的 baseURL,$api.use(name) 切换到别名端点后请求改用对应 baseURL。
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ error ? { error } : data }}</pre>
  </div>
</template>

Imperative Usage

Outside a useFetch context, obtain an endpoint instance via $api.use(name) and make raw requests directly:

<script setup lang="ts">
const { $api } = useNuxtApp()

const data = ref<unknown>(null)
const pending = ref(false)
const error = ref<string | null>(null)

async function call() {
  pending.value = true
  error.value = null
  try {
    data.value = await $api.use('v2')('/profile')
  }
  catch (err) {
    error.value = err instanceof Error ? err.message : String(err)
  }
  finally {
    pending.value = false
  }
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" icon="i-lucide-zap" :loading="pending" @click="call">
      $api.use('v2')('/profile')
    </UButton>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ error ? { error } : data }}</pre>
  </div>
</template>

Active Runtime Config

runtimeConfig.public.movkApi exposes the endpoint list and default endpoint name for runtime debugging:

nuxt.configmovk.api.endpoints 注册新别名后即可被 endpoint 选项引用。

{
  "defaultEndpoint": "default",
  "endpoints": {
    "default": {
      "baseURL": "/api"
    },
    "v2": {
      "baseURL": "/api/demo/v2"
    }
  }
}
<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
</script>

<template>
  <div class="flex flex-col gap-3">
    <p class="text-sm text-muted">
<code>nuxt.config</code><code>movk.api.endpoints</code> 注册新别名后即可被 <code>endpoint</code> 选项引用。
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ { defaultEndpoint: publicConfig.defaultEndpoint, endpoints: publicConfig.endpoints } }}</pre>
  </div>
</template>
Learn how to configure multiple API endpoints

Auth Integration

The module automatically integrates with nuxt-auth-utils: after login it reads the token from session[sessionTokenPath] and all $api requests automatically inject the Authorization header. On a 401 response, the auth.unauthorized strategy clears the session or redirects to the login page.

Current Session

The reactive session exposed by useUserSession; call fetch() after login/logout to sync state:

未登录

sessionTokenPath: token

{
  "id": "776e9a74-6c83-4a37-b09f-106bc27ae76e"
}
<script setup lang="ts">
const { session, loggedIn, fetch: refreshSession } = useUserSession()
const { $api } = useNuxtApp()
const publicConfig = useRuntimeConfig().public.movkApi

const loginPending = ref(false)
const logoutPending = ref(false)

async function login() {
  loginPending.value = true
  try {
    await $api('/demo/login', { method: 'POST' })
    await refreshSession()
  }
  finally {
    loginPending.value = false
  }
}

async function logout() {
  logoutPending.value = true
  try {
    await $api('/demo/logout', { method: 'POST' })
    await refreshSession()
  }
  finally {
    logoutPending.value = false
  }
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-2 flex-wrap">
      <UBadge :color="loggedIn ? 'success' : 'neutral'" variant="subtle">
        {{ loggedIn ? '已登录' : '未登录' }}
      </UBadge>
      <UButton
        v-if="!loggedIn"
        size="sm"
        :loading="loginPending"
        icon="i-lucide-log-in"
        @click="login"
      >
        登录(写入 session.token)
      </UButton>
      <UButton
        v-else
        size="sm"
        color="error"
        variant="outline"
        :loading="logoutPending"
        icon="i-lucide-log-out"
        @click="logout"
      >
        登出(清除 session)
      </UButton>
    </div>
    <p class="text-xs text-muted">
      sessionTokenPath: {{ publicConfig.auth?.sessionTokenPath }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ session }}</pre>
  </div>
</template>

Calling a Protected Endpoint

useApiFetch automatically carries the Authorization header when accessing /demo/protected; returns 401 when not logged in:

未登录时拦截器返回 401;已登录则携带 Authorization 头

<script setup lang="ts">
const protectedRes = useApiFetch('/demo/protected', { immediate: false })

const display = computed(() => {
  if (protectedRes.error.value) {
    return {
      statusCode: protectedRes.error.value.statusCode,
      message: protectedRes.error.value.message
    }
  }
  return protectedRes.data.value
})
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" icon="i-lucide-shield-check" @click="protectedRes.execute()">
      请求 /demo/protected
    </UButton>
    <p class="text-xs text-muted">
      未登录时拦截器返回 401;已登录则携带 Authorization 头
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ display }}</pre>
  </div>
</template>

401 Handling Strategy

auth.unauthorized controls redirect and session clearing; this docs site disables redirect for demonstration purposes:

auth.unauthorized 控制 401 时的行为;docs 站点关闭重定向便于演示,生产建议启用 redirect: true + loginPath

{
  "redirect": false,
  "clearSession": true,
  "loginPath": "/login"
}
<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
</script>

<template>
  <div class="flex flex-col gap-3">
    <p class="text-sm text-muted">
      <code>auth.unauthorized</code> 控制 401 时的行为;docs 站点关闭重定向便于演示,生产建议启用 <code>redirect: true</code> + <code>loginPath</code>    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ publicConfig.auth?.unauthorized }}</pre>
  </div>
</template>

Token Injection Format

headerName + tokenType determine the final request header format; the backend echoes the authHeader field for verification:

{
  "headerName": "Authorization",
  "tokenType": "Bearer",
  "echoed": null
}

登录后再次请求,echoed 字段会展示拦截器实际注入的 Authorization 头。

<script setup lang="ts">
const publicConfig = useRuntimeConfig().public.movkApi
const protectedRes = useApiFetch('/demo/protected', { immediate: false })

const echoed = computed(() => {
  const data = protectedRes.data.value
  return data && typeof data === 'object'
    ? (data as { authHeader?: string }).authHeader
    : null
})
</script>

<template>
  <div class="flex flex-col gap-3">
    <UButton size="sm" variant="outline" icon="i-lucide-send" @click="protectedRes.execute()">
      请求 /demo/protected 查看注入
    </UButton>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ {
      headerName: publicConfig.auth?.headerName ?? 'Authorization',
      tokenType: publicConfig.auth?.tokenType ?? 'Bearer',
      echoed
    } }}</pre>
    <p class="text-xs text-muted">
      登录后再次请求,<code>echoed</code> 字段会展示拦截器实际注入的 Authorization 头。
    </p>
  </div>
</template>

Data Unwrapping and Business Validation

$api extracts business data by response.dataKey and validates the business code against successCodes by default. The two switches skipUnwrap / skipBusinessCheck let the caller take over per scenario. Select a mode in the dropdown to observe the difference in the returned structure:

  • default: Extracts business data by the default data field; the caller receives the de-enveloped object directly.
  • skip-unwrap: skipUnwrap: true — does not overwrite response._data, preserving the full code/message/data envelope.
  • skip-business-check: Legacy endpoints that don't return a code field — skip validation and still unwrap the business data by dataKey.
  • combined: Non-standard external envelopes — enable both switches to let the caller handle the response entirely.
default

按 dataKey(默认 data)抽取业务数据,调用方直接拿到去信封后的对象。

<script setup lang="ts">
const props = defineProps<{
  mode: 'default' | 'skip-unwrap' | 'skip-business-check' | 'combined'
}>()

const fetchers = {
  'default': useApiFetch('/profile', { key: 'unwrap-default', immediate: false }),
  'skip-unwrap': useApiFetch('/profile', { key: 'unwrap-skip-unwrap', immediate: false, skipUnwrap: true }),
  'skip-business-check': useApiFetch('/demo/legacy', { key: 'unwrap-skip-business-check', immediate: false, skipBusinessCheck: true }),
  'combined': useApiFetch('/demo/external', { key: 'unwrap-combined', immediate: false, skipBusinessCheck: true, skipUnwrap: true })
}

const active = computed(() => fetchers[props.mode])

const hints: Record<typeof props.mode, string> = {
  'default': '按 dataKey(默认 data)抽取业务数据,调用方直接拿到去信封后的对象。',
  'skip-unwrap': 'skipUnwrap: true 不重写 response._data,保留完整 code/message/data 信封。',
  'skip-business-check': 'legacy 接口未返回 code 字段,跳过校验后仍按 dataKey 解包出业务数据。',
  'combined': 'external 接口信封非标,同时开两开关由调用方完全自处理响应。'
}

watch(() => props.mode, () => active.value.execute(), { immediate: true })
</script>

<template>
  <div class="flex flex-col gap-3">
    <UBadge variant="subtle">
      {{ mode }}
    </UBadge>
    <p class="text-xs text-muted">
      {{ hints[mode] }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ active.data.value }}</pre>
  </div>
</template>

Toast Notifications

$api has built-in Toast notifications, determining success/failure by toast.successCodes. Supports two levels of customization: global defaults and per-request overrides. Select a mode in the dropdown and toggle the "inject error" switch to observe the success/failure branches:

  • default: No toast passed — inherits global config; both success and error use the default style.
  • silent: toast: false — disables all per-request notifications.
  • message: successMessage / errorMessage replace the default text.
  • error-only: { success: false } — disables success notification but keeps error notification.
  • custom: Overrides color, icon and other ToastProps for success and error separately.

未传 toast 时继承全局配置,成功 / 错误均按默认样式弹出。

<script setup lang="ts">
const props = defineProps<{
  mode: 'default' | 'silent' | 'message' | 'error-only' | 'custom'
}>()

const fail = ref(false)
const target = () => `/profile${fail.value ? '?fail=1' : ''}`

const fetchers = {
  'default': useApiFetch(target, { key: 'toast-default', immediate: false }),
  'silent': useApiFetch(target, { key: 'toast-silent', immediate: false, toast: false }),
  'message': useApiFetch(target, {
    key: 'toast-message',
    immediate: false,
    toast: { successMessage: '加载成功 ✓', errorMessage: '加载失败 ✗' }
  }),
  'error-only': useApiFetch(target, { key: 'toast-error-only', immediate: false, toast: { success: false } }),
  'custom': useApiFetch(target, {
    key: 'toast-custom',
    immediate: false,
    toast: {
      success: { color: 'secondary', icon: 'i-lucide-sparkles' },
      error: { color: 'warning', icon: 'i-lucide-triangle-alert' }
    }
  })
}

const active = computed(() => fetchers[props.mode])

const hints: Record<typeof props.mode, string> = {
  'default': '未传 toast 时继承全局配置,成功 / 错误均按默认样式弹出。',
  'silent': 'toast: false 关闭所有单次提示。',
  'message': 'successMessage / errorMessage 替换默认文案。',
  'error-only': '{ success: false } 关闭成功提示但保留错误提示。',
  'custom': '为成功 / 错误分别覆盖 color、icon 等 ToastProps。'
}

const display = computed(() => active.value.error.value
  ? { error: active.value.error.value.message }
  : active.value.data.value)
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex items-center gap-3 flex-wrap">
      <USwitch v-model="fail" label="注入错误(验证错误分支)" />
      <UButton size="sm" variant="outline" icon="i-lucide-send" @click="active.execute()">
        触发请求
      </UButton>
    </div>
    <p class="text-xs text-muted">
      {{ hints[mode] }}
    </p>
    <pre class="text-xs p-3 rounded bg-elevated overflow-auto">{{ display }}</pre>
  </div>
</template>

Using in Event Handlers

Using $api inside event handlers is the most common scenario:

<script setup lang="ts">
const { $api } = useNuxtApp()

// Form submission
async function handleCreate(formData: CreateUserRequest) {
  try {
    const user = await $api<User>('/users', {
      method: 'POST',
      body: formData
    })
    // Toast is shown automatically, no manual handling needed
    navigateTo(`/users/${user.id}`)
  }
  catch (error) {
    // Business error Toast is shown automatically
    // Add extra logic here if needed (e.g. highlight form fields)
  }
}

// Delete confirmation
async function handleDelete(id: string) {
  await $api(`/users/${id}`, { method: 'DELETE' })
  await refreshNuxtData() // Refresh page-cached data
}
</script>

Using in useAsyncData

When you need to aggregate multiple endpoints or do complex data processing, use $api inside useAsyncData:

const { $api } = useNuxtApp()

// Aggregate multiple endpoints
const { data: dashboard } = await useAsyncData('dashboard', async () => {
  const [users, stats, recent] = await Promise.all([
    $api<User[]>('/users'),
    $api<Stats>('/stats'),
    $api<Activity[]>('/activity/recent')
  ])
  return { users, stats, recent }
})

Wrapping a Custom Composable

app/composables/useDashboard.ts
export function useDashboard() {
  const { $api } = useNuxtApp()

  return useAsyncData('dashboard', async () => {
    const [users, stats] = await Promise.all([
      $api<User[]>('/users'),
      $api<Stats>('/stats')
    ])
    return { users, stats }
  })
}

Business Operation Composable

For write operations such as delete or approve, encapsulate the $api calls in a dedicated composable to keep page files lean:

app/composables/useUserActions.ts
export function useUserActions() {
  const { $api } = useNuxtApp()

  async function remove(id: string) {
    await $api(`/users/${id}`, { method: 'DELETE' })
    await refreshNuxtData()
  }

  async function create(data: CreateUserRequest) {
    return await $api<User>('/users', {
      method: 'POST',
      body: data,
    })
  }

  async function updateRole(id: string, role: UserRole) {
    return await $api<User>(`/users/${id}/role`, {
      method: 'PUT',
      body: { role },
      context: { toast: { successMessage: 'Role updated' } },
    })
  }

  return { remove, create, updateRole }
}
app/pages/users/index.vue
<script setup lang="ts">
// Data fetching with useApiFetch
const { data: users, refresh } = await useApiFetch<User[]>('/users')

// Write operations with $api composable
const { remove, updateRole } = useUserActions()
</script>

Per-Request Configuration

Pass per-request configuration via the context field:

const { $api } = useNuxtApp()

// Disable Toast
await $api('/users', {
  context: { toast: false }
})

// Custom Toast message
await $api('/users', {
  method: 'POST',
  body: formData,
  context: {
    toast: { successMessage: 'Created successfully' }
  }
})

// Skip business status code checking (returns the raw unwrapped data)
const raw = await $api('/external-api/data', {
  context: { skipBusinessCheck: true }
})
The context field is passed via ofetch's FetchOptions extension and is only recognized by Movk Nuxt's $api instance. The native $fetch does not process these options.

API

ApiInstance

Type definition of $api:

type ApiInstance = $Fetch & {
  use: (endpoint: string) => ApiInstance
}
$api(url, options)
Promise<T>
Send an API request. Supports all ofetch options plus the additional context field.
$api.use(endpoint)
ApiInstance
Switch to the specified endpoint and return that endpoint's $api instance. endpoint is typed as MovkApiEndpointName (a literal union merged by the module from the movk.api.endpoints declaration). Endpoint instances are internally cached; multiple calls return the same instance.

ApiFetchContext

Extended configuration passed via options.context:

toast
RequestToastOptions | false
Toast configuration. Set to false to disable all Toast notifications.
skipBusinessCheck
boolean
Skip business status code checking. When true, ApiError is not thrown, but the dataKey field is still unwrapped.
skipUnwrap
boolean
Skip data unwrapping. When true, the interceptor does not overwrite response._data; the caller receives the full code/message/data envelope. Orthogonal to skipBusinessCheck.
Copyright © 2025 - 2026 YiXuan - MIT License