$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:
<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.
<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
$api: typeof $fetch$api has the same API as Nuxt's $fetch, so you can use all the same options and type parameters:
// 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 UserProfileHow It Works
Automatic Bearer Token Injection
$api automatically attaches the access token to every request:
// 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:
- If automatic refresh is enabled,
$apimakes one shared refresh attempt viauseAuth().refresh(). - Only a successful refresh supplying a token permits one replay, preserving the request method and body.
- 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.
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)
<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:
<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:
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:
<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:
const { data } = await useAsyncData(
'client-only-data',
() => $api('/api/user/settings'),
{
server: false, // Only fetch on client
}
)When to Use $api vs Alternatives
| Scenario | Recommended 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 calls | event.context.user + $fetch |
| Simple data fetching with reactivity | $api + useAsyncData |
| Advanced fetch configuration | useFetch with manual auth |
Recommended Pattern
For most authenticated API calls, use $api with useAsyncData:
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)
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
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.
// ❌ WRONG - causes infinite loop
await $api('/auth/refresh')
// ✅ CORRECT - use the composable to update auth state too
await useAuth().refresh()This applies to:
/auth/refreshendpoint- 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:
onRequest({ options }) {
const event = useRequestEvent()
const ssrAccessToken = event?.context?.ssrAccessToken
if (ssrAccessToken) {
options.headers.set('Authorization', `Bearer ${ssrAccessToken}`)
}
}Key Differences from Client:
| Feature | Client Plugin | SSR Plugin |
|---|---|---|
| Token source | In-memory access token | event.context.ssrAccessToken |
| Token lifetime | 1 hour (default) | 5 minutes (default) |
| Auto-refresh on 401 | ✅ Yes | ❌ No |
| Retry logic | ✅ Yes (1 retry) | ❌ No |
| Initialization | On mount | On each request |
| Token rotation | Via /auth/refresh | Generated 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
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
$apiplugin - No SSR token generation
$apionly works on client-sideuseAsyncDatawithserver: truewon't have auth tokens
Token Storage
Client-Side Storage
Access tokens are stored in-memory only:
// 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:
// 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:
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_failederror_description=Session expired. Please log in again.
Handle in your error page:
<!-- 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:
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:
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:
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:
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:
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:
// 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 | $fetch | useFetch | Manual 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 | ✅ Yes | Depends |
| Retry logic | ✅ 401 only | ❌ No | ⚙️ Configurable | 👤 Manual |
Use in useAsyncData | ✅ Recommended | ✅ Yes | N/A | ✅ Yes |
| Best for | Authenticated APIs | Public APIs | Simple data fetching | Custom 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:
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