Skip to content

$api Plugin Reference ​

The $api plugin provides a custom $fetch instance with automatic bearer token injection and token refresh handling. This is the recommended way to make authenticated API calls in Nuxt Aegis applications.

Quick Start ​

Access $api from the Nuxt app instance:

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

// Make authenticated API call - bearer token added automatically
const userData = await $api('/api/user/profile')
</script>

Key Features:

  • ✅ Automatic bearer token injection
  • ✅ Automatic token refresh on 401 errors
  • ✅ Works on both server (SSR) and client
  • ✅ Type-safe with TypeScript generics
  • ✅ Same API as Nuxt's $fetch

useAegisFetch ​

useAegisFetch is an auto-imported authenticated version of Nuxt's useFetch, created with createUseFetch. It resolves the current app's $api when called and reuses its token injection and client refresh/retry behavior unchanged. It can also be imported explicitly from #imports.

vue
<script setup lang="ts">
const { data, status, error, refresh, execute, clear } = await useAegisFetch('/api/user/profile', {
  key: 'profile',
})
</script>

Standard useFetch options, response inference, refs, and controls are preserved, including reactive requests and queries, transform, pick, default, and immediate. With SSR enabled, Nuxt transfers fetched data in its payload and reuses it during hydration without a duplicate request. Authentication behavior is the same as $api on each platform.

Nuxt generates normal call-site keys when key is omitted. An explicit key shares data between calls, following ordinary useFetch semantics. Keys are not authentication-scoped, and changing identity does not automatically clear or refetch cached data. Manage identity-sensitive data explicitly; do not include tokens in keys or returned data.

Keep using $api for imperative requests and mutations. useAegisFetch adds no authentication options or separate data lifecycle.

Type Signature ​

typescript
$api: typeof $fetch

$api has the same API as Nuxt's $fetch, so you can use all the same options and type parameters:

typescript
// With TypeScript generics for type safety
interface UserProfile {
  id: string
  name: string
  email: string
}

const profile = await $api<UserProfile>('/api/user/profile')
// profile is typed as UserProfile

How It Works ​

Automatic Bearer Token Injection ​

$api automatically attaches the access token to every request:

typescript
// You write this:
await $api('/api/user/profile')

// $api automatically adds:
// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

No token available? If no access token exists, the request proceeds without the Authorization header (useful for public endpoints).

Automatic Token Refresh on 401 ​

When a request returns a 401 Unauthorized response:

  1. If automatic refresh is enabled, $api makes one shared refresh attempt via useAuth().refresh().
  2. Only a successful refresh supplying a token permits one replay, preserving the request method and body.
  3. Refresh 401 clears auth and redirects to the configured error page. Network, 5xx, and forbidden failures reject without redirecting or clearing identity.

If the replay returns 401, the API failure propagates without another refresh or session invalidation. Extra retry options cannot bypass this limit. An API 401 alone does not prove the refresh session is invalid.

typescript
const data = await $api('/api/protected/resource')

$api rejects with the refresh failure when refresh fails. useAegisFetch exposes that failure in its error ref; useAuth().error supplies the structured category for both consumers.

SSR vs Client Behavior ​

Client-Side (Browser):

  • Uses the in-memory access token (1 hour lifetime by default)
  • Automatically refreshes on 401 errors
  • Retries failed requests once after refresh

Server-Side (SSR):

  • Uses a short-lived SSR access token (5 minutes by default)
  • No retry or refresh logic (tokens are generated fresh per request)
  • Token never sent to client (stays in event.context)
vue
<script setup lang="ts">
const { $api } = useNuxtApp()

// Works on both server (SSR) and client
const { data } = await useAsyncData('profile', 
  () => $api('/api/user/profile')
)
</script>

Common Patterns ​

With useAsyncData ​

Combine $api with useAsyncData for reactive server/client data fetching:

vue
<template>
  <div>
    <div v-if="pending">Loading...</div>
    <div v-else-if="error">{{ error.message }}</div>
    <ul v-else>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

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

const { data: items, pending, error } = await useAsyncData(
  'user-items',
  () => $api<Item[]>('/api/user/items'),
  {
    server: true, // Fetch during SSR (requires enableSSR: true)
  }
)
</script>

POST Requests ​

$api works with all HTTP methods:

typescript
const { $api } = useNuxtApp()

// POST with body
const newItem = await $api('/api/items', {
  method: 'POST',
  body: {
    title: 'New Item',
    description: 'Item description'
  }
})

// PUT request
await $api(`/api/items/${id}`, {
  method: 'PUT',
  body: updatedData
})

// DELETE request
await $api(`/api/items/${id}`, {
  method: 'DELETE'
})

Error Handling ​

Handle errors like any async operation:

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

async function fetchUserData() {
  try {
    const data = await $api('/api/user/profile')
    return data
  } catch (error) {
    console.error('Failed to fetch user data:', error)
    // Handle error (e.g., show notification)
  }
}
</script>

Skip SSR ​

Fetch only on the client by setting server: false:

typescript
const { data } = await useAsyncData(
  'client-only-data',
  () => $api('/api/user/settings'),
  {
    server: false, // Only fetch on client
  }
)

When to Use $api vs Alternatives ​

ScenarioRecommended Approach
Authenticated API calls✅ $api
Public API endpoints$fetch or $api (both work)
External APIs (non-authenticated)$fetch
Implementing auth endpoints$fetch (avoid circular refresh)
Server-only authenticated callsevent.context.user + $fetch
Simple data fetching with reactivity$api + useAsyncData
Advanced fetch configurationuseFetch with manual auth

Recommended Pattern

For most authenticated API calls, use $api with useAsyncData:

typescript
const { data } = await useAsyncData('key', () => $api('/endpoint'))

Overview ​

$api is automatically available in all Vue components, composables, and Nuxt contexts via useNuxtApp().$api. It wraps Nuxt's $fetch with authentication-specific interceptors.

Implementation Details ​

Client-Side Plugin ​

Location: src/runtime/app/plugins/api.client.ts

The client-side plugin creates a custom $fetch instance with the following interceptors:

1. Request Interceptor (Token Injection) ​

typescript
onRequest({ options }) {
  const token = getAccessToken()
  
  if (token) {
    options.headers.set('Authorization', `Bearer ${token}`)
  }
}

Behavior:

  • Retrieves access token from in-memory storage (not sessionStorage/localStorage)
  • Adds Authorization: Bearer <token> header to every request
  • If no token exists, request proceeds without the header

2. Response Error Interceptor (Auto-Refresh on 401) ​

The interceptor delegates refresh and auth-state transitions to useAuth(). Concurrent requests share one attempt and, for session rejection, one redirect. It does not independently clear tokens or classify failures.

3. Retry Configuration ​

typescript
retry: autoRefreshEnabled ? 1 : 0,
retryStatusCodes: [401],

Behavior:

  • Disables replay until refresh successfully supplies a token
  • Replays once after successful refresh; a replayed 401 propagates
  • No retry if automaticRefresh: false

4. Token Refresh Mechanism ​

Refresh uses $fetch, not $api, to avoid recursive interception. The refresh cookie is sent automatically. Success returns the new in-memory token; failure rejects. Shared work is cleared after either outcome so a later call can retry.

Critical Warning: Circular Reference Prevention

NEVER use $api to call the /auth/refresh endpoint or any authentication-related endpoints. Always use $fetch directly in these cases to avoid triggering the 401 interceptor, which would cause an infinite refresh loop.

typescript
// ❌ WRONG - causes infinite loop
await $api('/auth/refresh')

// ✅ CORRECT - use the composable to update auth state too
await useAuth().refresh()

This applies to:

  • /auth/refresh endpoint
  • Custom authentication logic
  • Token validation endpoints
  • Any endpoint that might return 401 during normal auth flow

5. Automatic Initialization on Mount ​

On app:mounted, the plugin calls ensureResolved() unless on a callback/public route or an access token already exists. Already-resolved state is left intact.

Behavior:

  • Runs after Nuxt app hydration completes
  • Skips if already authenticated
  • Skips on OAuth callback pages
  • Skips on configured public routes
  • Attempts to restore session from httpOnly refresh cookie
  • Contains startup failures so public content can render; unknown state and structured errors remain available for explicit retry
  • Expected startup 401 resolves quietly to guest; no background retry loop is started

Server-Side Plugin (SSR) ​

Location: src/runtime/app/plugins/api.server.ts

The server-side plugin creates a simpler $fetch instance for SSR:

typescript
onRequest({ options }) {
  const event = useRequestEvent()
  const ssrAccessToken = event?.context?.ssrAccessToken
  
  if (ssrAccessToken) {
    options.headers.set('Authorization', `Bearer ${ssrAccessToken}`)
  }
}

Key Differences from Client:

FeatureClient PluginSSR Plugin
Token sourceIn-memory access tokenevent.context.ssrAccessToken
Token lifetime1 hour (default)5 minutes (default)
Auto-refresh on 401✅ Yes❌ No
Retry logic✅ Yes (1 retry)❌ No
InitializationOn mountOn each request
Token rotationVia /auth/refreshGenerated fresh per request

Why simpler?

  • SSR tokens are generated fresh on each server request
  • No need for refresh logic (tokens are short-lived and disposable)
  • SSR tokens never sent to client (stays in event.context)
  • Focus on token attachment, not lifecycle management

Configuration ​

Module Options ​

typescript
export default defineNuxtConfig({
  nuxtAegis: {
    tokenRefresh: {
      // Enable/disable automatic token refresh on 401
      automaticRefresh: true, // default: true
      
      // SSR token expiration time
      ssrTokenExpiry: '5m', // default: '5m'
    },
    
    // Enable/disable SSR support
    enableSSR: true, // default: true when ssr: true
    
    redirect: {
      // Destination for a rejected refresh session (401)
      error: '/auth-failed', // default: '/'
    },
  }
})

Configuration Impact ​

automaticRefresh: false:

  • Disables 401 auto-refresh
  • Disables retry logic
  • User must manually handle token expiration
  • Still adds bearer token to requests

enableSSR: false:

  • Disables server-side $api plugin
  • No SSR token generation
  • $api only works on client-side
  • useAsyncData with server: true won't have auth tokens

Token Storage ​

Client-Side Storage ​

Access tokens are stored in-memory only:

typescript
// In src/runtime/app/composables/useAuth.ts
let accessTokenStore: string | null = null

export function getAccessToken(): string | null {
  return accessTokenStore
}

export function setAccessToken(token: string | null): void {
  accessTokenStore = token
}

Security Benefits:

  • ✅ XSS Protection - Not accessible via localStorage/sessionStorage
  • ✅ No Persistence - Cleared on page refresh
  • ✅ No HTML Exposure - Never in server-rendered HTML
  • ✅ Memory Only - Can't be extracted from browser storage

Trade-offs:

  • ❌ Lost on page refresh (requires automatic refresh from cookie)
  • ❌ Not shared across tabs
  • ❌ Requires client-side initialization

Server-Side Storage ​

SSR tokens are stored in event.context:

typescript
// In server middleware
event.context.ssrAccessToken = generateShortLivedToken(claims, '5m')

Security Benefits:

  • ✅ Never sent to client - Stays on server
  • ✅ Short-lived - 5 minute default expiration
  • ✅ Request-scoped - Generated per request
  • ✅ No rotation needed - Disposable tokens

Error Handling ​

Refresh Failure ​

Only a definitive refresh-session rejection (session_expired, refresh 401) triggers the configured redirect:

typescript
const errorUrl = validateRedirectPath(
  config.public.nuxtAegis.redirect?.error || '/'
)
await navigateTo(
  `${errorUrl}?error=token_refresh_failed&error_description=${encodeURIComponent('Session expired. Please log in again.')}`
)

Query Parameters:

  • error=token_refresh_failed
  • error_description=Session expired. Please log in again.

Handle in your error page:

vue
<!-- pages/auth-failed.vue -->
<script setup lang="ts">
const route = useRoute()
const errorMessage = route.query.error_description || 'Authentication failed'
</script>

<template>
  <div>
    <h1>Authentication Error</h1>
    <p>{{ errorMessage }}</p>
    <button @click="navigateTo('/auth/google')">
      Log In Again
    </button>
  </div>
</template>

Network Errors ​

Network, server, and forbidden refresh failures do not redirect or replay the failed API call. Existing identity is preserved, but its token is not renewed. useAegisFetch reports the refresh failure through its error ref:

typescript
const auth = useAuth()
const profile = await useAegisFetch('/api/user/profile')

async function retryProfile() {
  try {
    await auth.refresh()
    await profile.refresh()
  } catch {
    console.error(auth.error.value?.message)
  }
}

Read profile.error.value for the request failure and auth.error.value?.category for the refresh category. Only retry the protected fetch after auth refresh succeeds. There is no automatic background retry.

Timeout ​

Set custom timeout using standard $fetch options:

typescript
const data = await $api('/api/slow-endpoint', {
  timeout: 10000, // 10 seconds
})

Advanced Usage ​

Type Safety with Generics ​

Define response types for type-safe API calls:

typescript
interface UserProfile {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

const profile = await $api<UserProfile>('/api/user/profile')
// profile is typed as UserProfile

console.log(profile.role) // TypeScript knows this is 'admin' | 'user'

Custom Headers ​

Add custom headers alongside the automatic Authorization header:

typescript
const data = await $api('/api/endpoint', {
  headers: {
    'X-Custom-Header': 'value',
    'Content-Type': 'application/json',
  }
})

Request Interceptors ​

You cannot add additional interceptors to $api after creation. For custom interceptor logic, create your own $fetch instance:

typescript
const customFetch = $fetch.create({
  onRequest({ options }) {
    // Custom logic
  },
  async onResponseError({ response }) {
    // Custom error handling
  }
})

Conditional Bearer Token ​

$api always adds the bearer token if available. To skip authentication:

typescript
// Use $fetch directly for public endpoints
const publicData = await $fetch('/api/public/data')

// Or use $api (works fine, just adds token if available)
const data = await $api('/api/public/data')

Comparison Table ​

Feature$api$fetchuseFetchManual Auth
Auto bearer token✅ Yes❌ No❌ No👤 Manual
Auto refresh on 401✅ Yes❌ No❌ No👤 Manual
SSR support✅ Yes✅ Yes✅ Yes👤 Manual
Type safety✅ Generic✅ Generic✅ Generic👤 Manual
Reactivity❌ No❌ No✅ YesDepends
Retry logic✅ 401 only❌ No⚙️ Configurable👤 Manual
Use in useAsyncData✅ Recommended✅ YesN/A✅ Yes
Best forAuthenticated APIsPublic APIsSimple data fetchingCustom auth

When to Use Each ​

Use $api:

  • ✅ Authenticated API calls to your backend
  • ✅ Protected endpoints requiring bearer tokens
  • ✅ When you want automatic token refresh
  • ✅ Most common use case

Use $fetch:

  • ✅ Public API endpoints
  • ✅ External APIs (non-authenticated)
  • ✅ Implementing auth endpoints (avoid circular refresh)
  • ✅ When you need full control

Use useFetch:

  • ✅ Simple data fetching with built-in reactivity
  • ✅ When you don't need authentication
  • ✅ Caching and deduplication needed
  • ❌ Less flexible for complex auth scenarios

Use Manual Authorization:

  • ✅ Custom authentication schemes
  • ✅ Non-bearer token auth (API keys, etc.)
  • ✅ Special header requirements
  • ❌ More boilerplate code

Debugging ​

Enable debug logging to see token refresh and $api behavior:

typescript
export default defineNuxtConfig({
  nuxtAegis: {
    logging: {
      level: 'debug',
    }
  }
})

Console output:

[Aegis] Attempting token refresh...
[Aegis] Token refresh successful
[Aegis] API request: GET /api/user/profile
[Aegis] Request completed: 200 OK

Released under the MIT License.