JSON in Nuxt 3: useFetch, server/api Routes, and $fetch

Last updated:

Nuxt 3's useFetch('/api/data') fetches JSON with built-in SSR support — data fetched on the server is serialized and sent to the client to prevent double-fetching. For full control, useAsyncData('key', () => $fetch('/api/data')) lets you define a cache key. Server API routes in server/api/users.get.ts return plain objects or defineLazyEventHandler responses that Nuxt auto-serializes to JSON. For client-only requests, use $fetch (Nuxt's oFetch wrapper) which automatically parses JSON responses. This guide covers 5 topics: useFetch for SSR-aware JSON fetching, server/api route JSON responses, $fetch client requests, JSON error handling with createError, and typed API routes with TypeScript.

Validate your Nuxt JSON responses

Paste a JSON payload from your Nuxt server route into Jsonic's formatter to inspect nested data and spot missing fields.

Open JSON Formatter

Fetch JSON with useFetch

Bottom line: useFetch('/api/data') is the primary way to load JSON in Nuxt 3 pages and components. It runs on the server during SSR, serializes the result into the HTML payload, and hydrates on the client from that payload — never making a second network request for the same data.

useFetch returns four reactive refs: data (the parsed JSON), error (any fetch or server error), pending (loading boolean), and refresh (a function to re-fetch). Use the pick option to trim the response to specific keys — useful when the API returns more fields than the component needs and you want to reduce payload size. The transform option lets you reshape the response before it is stored. lazy: true defers the fetch to client-side so the page renders immediately without waiting for the data.

// Basic useFetch — SSR-aware, no double fetch
const { data: users, error, pending } = await useFetch('/api/users')
// data is Ref<User[] | null>, error is Ref<FetchError | null>

// Pick only the fields you need (reduces payload size)
const { data: user } = await useFetch('/api/users/1', {
  pick: ['id', 'name', 'email'],
})

// Transform the response before storing
const { data: names } = await useFetch<{ name: string }[]>('/api/users', {
  transform: (users) => users.map((u) => u.name),
})

// Re-fetch on demand (e.g. after a form submit)
const { data, refresh } = await useFetch('/api/users')
async function reloadUsers() {
  await refresh()
}

// Lazy fetch — page renders immediately, data loads client-side
const { data: posts, pending } = useFetch('/api/posts', { lazy: true })

// External API with query params
const { data: repos } = await useFetch('https://api.github.com/users/nuxt/repos', {
  query: { per_page: 10, sort: 'updated' },
})

// Type the response explicitly
const { data: product } = await useFetch<Product>('/api/products/42')

Create JSON Server API Routes

Bottom line: files inside server/api/ become HTTP endpoints automatically. The filename encodes the HTTP method: users.get.ts handles GET /api/users, users.post.ts handles POST /api/users. Returning a plain object from defineEventHandler auto-serializes it to JSON with the correct Content-Type header.

Nuxt's server layer is powered by H3 and Nitro. Use readBody(event) to parse the incoming JSON request body (H3 handles the parsing), getQuery(event) for URL query parameters, and event.context.params for dynamic route segments. setResponseStatus(event, 201) sets the HTTP status code. Dynamic routes use bracket syntax in filenames: server/api/users/[id].get.ts captures the id segment.

// server/api/users.get.ts  →  GET /api/users
export default defineEventHandler(async () => {
  // Return any value — Nuxt serializes it to JSON automatically
  return [
    { id: 1, name: 'Alice', role: 'admin' },
    { id: 2, name: 'Bob', role: 'user' },
  ]
})

// server/api/users.post.ts  →  POST /api/users
export default defineEventHandler(async (event) => {
  const body = await readBody<{ name: string; role: string }>(event)
  if (!body.name) {
    throw createError({ statusCode: 400, message: 'name is required' })
  }
  setResponseStatus(event, 201)
  return { id: Date.now(), ...body }
})

// server/api/users/[id].get.ts  →  GET /api/users/:id
export default defineEventHandler(async (event) => {
  const id = Number(event.context.params?.id)
  if (isNaN(id)) throw createError({ statusCode: 400, message: 'Invalid id' })
  const user = await db.findUser(id)   // your data layer
  if (!user) throw createError({ statusCode: 404, message: 'User not found' })
  return user
})

// server/api/search.get.ts  →  GET /api/search?q=alice
export default defineEventHandler((event) => {
  const { q } = getQuery(event)
  return { query: q, results: [] }
})

Client-Side JSON with $fetch

Bottom line: $fetch is Nuxt's auto-imported alias for ofetch — use it for imperative, client-triggered requests like form submissions, button actions, or Pinia store methods. It auto-parses JSON responses and throws a FetchError on non-2xx status codes.

Unlike useFetch, $fetch does not deduplicate or cache results — each call makes a new request. If you call $fetch directly in a component's setup() function, it will execute on both server and client (double-fetch). Wrap it in useAsyncData to get SSR deduplication. For interceptors (adding auth headers, logging), configure $fetch globally in a Nuxt plugin using ofetch.create().

// GET — auto-parses JSON, throws FetchError on non-2xx
const user = await $fetch<User>('/api/users/1')

// POST with JSON body
const created = await $fetch<User>('/api/users', {
  method: 'POST',
  body: { name: 'Alice', role: 'admin' },  // $fetch JSON-stringifies automatically
})

// Error handling — FetchError contains the parsed JSON error body
try {
  const data = await $fetch('/api/users/999')
} catch (err: unknown) {
  if (err && typeof err === 'object' && 'data' in err) {
    console.error('Server error:', (err as { data: unknown }).data)
  }
}

// $fetch in a Pinia action (client-only — no double-fetch risk)
// store/users.ts
export const useUserStore = defineStore('users', {
  state: () => ({ list: [] as User[] }),
  actions: {
    async load() {
      this.list = await $fetch<User[]>('/api/users')
    },
    async create(payload: { name: string }) {
      const user = await $fetch<User>('/api/users', { method: 'POST', body: payload })
      this.list.push(user)
    },
  },
})

// plugins/fetch.ts — add Authorization header to every $fetch call
export default defineNuxtPlugin(() => {
  const authFetch = $fetch.create({
    onRequest({ options }) {
      const token = useCookie('token').value
      if (token) options.headers = { ...options.headers, Authorization: `Bearer ${token}` }
    },
  })
  return { provide: { apiFetch: authFetch } }
})

Handle JSON Errors in Nuxt

Bottom line: throw createError({ statusCode, message } in server routes to return a structured JSON error body. On the client, the error ref from useFetch holds the error, and Nuxt's error.vue page handles unhandled top-level errors.

H3 serializes createError as a JSON object with statusCode, statusMessage, and message fields. On the client, the error.value ref in useFetch contains a FetchError whose data property holds this parsed JSON. For fatal errors that should show the error page, call throw createError({ fatal: true } or use showError(error) from useError(). The global error.vue page at the project root receives the error as a error prop and can display a user-friendly message.

// server/api/products/[id].get.ts — structured JSON errors
export default defineEventHandler(async (event) => {
  const id = Number(event.context.params?.id)
  if (isNaN(id)) {
    throw createError({ statusCode: 400, message: 'id must be a number' })
  }
  const product = await db.findProduct(id)
  if (!product) {
    throw createError({ statusCode: 404, message: `Product ${id} not found` })
  }
  return product
})
// Error body sent to client: { statusCode: 404, statusMessage: 'Not Found', message: '...' }

// Component — handle error.value from useFetch
const { data: product, error } = await useFetch(`/api/products/${id}`)
// In template: <p v-if="error">{{ error.data?.message }}</p>

// Client-side $fetch error handling
try {
  const data = await $fetch('/api/products/99')
} catch (err: unknown) {
  const fetchErr = err as { statusCode?: number; data?: { message?: string } }
  if (fetchErr.statusCode === 404) {
    console.warn('Not found:', fetchErr.data?.message)
  }
}

// error.vue — root-level error page (project root, not app/)
// <script setup lang="ts">
// const props = defineProps<{ error: { statusCode: number; message: string } }>()
// function handleBack() { clearError({ redirect: '/' }) }
// </script>
// <template>
//   <div>
//     <h1>{{ error.statusCode }}</h1>
//     <p>{{ error.message }}</p>
//     <button @click="handleBack">Go home</button>
//   </div>
// </template>

Typed Server Routes and Auto-Imports

Bottom line: TypeScript infers the return type of defineEventHandler automatically. On the client, useFetch<T> and $fetch<T> accept a generic type parameter. Nuxt's Nitro typed routes feature (experimental) goes further — it infers $fetch response types from the server handler without any explicit annotation.

All Nuxt composables (useFetch, useAsyncData, $fetch, defineEventHandler, readBody, createError) are auto-imported — you never need to import them manually. For shared types between server and client, define interfaces in a types/ directory at the project root or inside shared/types/ — Nuxt's auto-import system also scans these directories for type exports. Zod works well for runtime validation of incoming request bodies; combine it with TypeScript inference to get both compile-time and runtime safety.

// types/api.ts — shared between server and client
export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

// server/api/users.get.ts — return type inferred automatically
export default defineEventHandler(async (): Promise<User[]> => {
  return db.getUsers()
})

// Component — $fetch<T> for typed client requests
const users = await $fetch<User[]>('/api/users')
//    ^? User[]

// useFetch<T> — data is Ref<T | null>
const { data: user } = await useFetch<User>('/api/users/1')
//           ^? Ref<User | null>

// Zod validation in server route
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'user']).default('user'),
})

// server/api/users.post.ts
export default defineEventHandler(async (event) => {
  const raw = await readBody(event)
  const result = CreateUserSchema.safeParse(raw)
  if (!result.success) {
    throw createError({ statusCode: 422, message: result.error.message })
  }
  const user = result.data  // typed as { name: string; email: string; role: 'admin' | 'user' }
  return await db.createUser(user)
})

JSON in Nuxt State Management

Bottom line: useState('key', () => initialValue) creates server-hydrated reactive state — the value set on the server is serialized into the HTML payload and available on the client with no extra fetch. useNuxtData('key') lets you read the cached result of a useFetch or useAsyncData call by its key from anywhere in the app.

useState is Nuxt's SSR-safe alternative to ref for shared state. Because the initial value is serialized from server to client, it must be JSON-serializable — no functions, class instances, or circular references. Use it to share configuration, feature flags, or bootstrapped API data across multiple components without re-fetching. useNuxtData('users') accesses the cached payload of a useFetch('/api/users', { key: 'users' }) call — useful in child components that need the same data without triggering a second request.

// composables/useAppConfig.ts — shared server-hydrated JSON state
export function useAppConfig() {
  return useState<AppConfig>('app-config', () => ({
    featureFlags: {},
    theme: 'light',
    version: '1.0.0',
  }))
}

// pages/index.vue — set state on the server
const config = useAppConfig()
// Mutate on the client — reactive across all components using the same key
function toggleTheme() {
  config.value.theme = config.value.theme === 'light' ? 'dark' : 'light'
}

// Parent component — fetch users with a named key
const { data: users } = await useFetch<User[]>('/api/users', { key: 'users' })

// Child component — access cached users without re-fetching
const { data: users } = useNuxtData<User[]>('users')
// data is the same Ref already populated by the parent — no network request

// Invalidate and refresh
async function addUser(payload: Partial<User>) {
  await $fetch('/api/users', { method: 'POST', body: payload })
  await refreshNuxtData('users')  // re-fetches the 'users' key
}

// Optimistic update pattern
async function deleteUser(id: number) {
  const { data } = useNuxtData<User[]>('users')
  const previous = [...(data.value ?? [])]
  data.value = data.value?.filter((u) => u.id !== id) ?? []   // optimistic
  try {
    await $fetch(`/api/users/${id}`, { method: 'DELETE' })
  } catch {
    data.value = previous  // rollback on error
  }
}

FAQ

How do I fetch JSON from an API in Nuxt 3?

Use const { data, error } = await useFetch('/api/endpoint') inside a page or component. useFetch runs on the server during SSR and serializes the result to the client — preventing a second network request on hydration. For external APIs, pass the full URL. For client-only or imperative fetches (button clicks, form submits), use await $fetch('/api/endpoint') directly.

How do I create a JSON API endpoint in Nuxt 3?

Create a file in server/api/. For GET /api/users, name it server/api/users.get.ts and export export default defineEventHandler(() => users). Nuxt serializes the returned value to JSON automatically. Use readBody(event) for POST request bodies, getQuery(event) for query params, and createError({ statusCode: 404 }) to return JSON error responses.

What is the difference between useFetch and $fetch in Nuxt?

useFetch is a composable for use in components and pages — it is SSR-aware, returns reactive refs (data, error, pending), and prevents double-fetching via the Nuxt payload cache. $fetch is an imperative function for client-triggered requests — it auto-parses JSON but is not SSR-deduplicated on its own. Wrap $fetch in useAsyncData if you need SSR behavior with a custom cache key.

How do I handle JSON errors in Nuxt 3?

In server routes, throw createError({ statusCode: 404, message: 'Not found' }) — H3 serializes it as a JSON error body. In components, read error.value from useFetch and display error.value.data?.message. For unhandled fatal errors, define an error.vue page at the project root — it receives the error as a prop and is shown automatically. For $fetch errors, wrap in try/catch and read the FetchError.data property.

How do I type my JSON API responses in Nuxt 3?

Pass a generic type to useFetch<User[]>('/api/users') or $fetch<User[]>('/api/users'). For server routes, annotate the handler return type: defineEventHandler(async (): Promise<User[]> => { ... }). Define shared interfaces in types/ at the project root — Nuxt auto-imports them. Use Zod's safeParse in server routes for runtime validation of incoming request bodies.

How do I avoid double-fetching JSON in Nuxt SSR?

Always use useFetch or useAsyncData for data that must be available at render time — never call $fetch directly in setup() without wrapping it. Nuxt serializes the server fetch result into the __NUXT__ payload in the HTML; on hydration, useFetch reads from the payload cache and skips the network request. You can inspect window.__NUXT__ in the browser console to verify data is being transferred correctly.

Further reading and primary sources

  • Nuxt useFetch docsOfficial reference for useFetch options: pick, transform, lazy, key, and the returned data, error, pending, and refresh refs
  • Nuxt server routesOfficial guide to the server/api/ directory, file naming conventions for HTTP methods, dynamic route segments, and Nitro plugins
  • $fetch docsOfficial reference for $fetch — Nuxt's auto-imported oFetch wrapper for imperative JSON requests and interceptor configuration
  • Nuxt error handlingOfficial guide to createError, error.vue, useError, showError, and clearError for handling server and client JSON errors
  • oFetchThe underlying fetch library powering $fetch — covers FetchError, request/response interceptors, and retry configuration

Validate your Nuxt JSON responses

Paste a JSON payload from your Nuxt server route into Jsonic's formatter to inspect nested data and spot missing fields.

Open JSON Formatter