← All guides

Mock a REST API for your Nuxt app in 60 seconds

You ran npx nuxi init, wrote the page, and now you need data. The real API isn't ready (it never is), so the choice is: hard-code an array, write throwaway server/api fixtures, or point useAsyncData at a real hosted API with realistic data β€” one whose error.vue and loading skeletons you can actually trigger on demand. This guide does the third one.

Every snippet below was run before publishing, in a fresh nuxi init project (Nuxt 4.5, Vue 3.5, TypeScript) β€” SSR output curl-checked, client-side navigation clicked in a real browser, vue-tsc clean, skeleton timing measured. The three gotchas below come from that run, not from docs.

1. Get an API (10 seconds)

Click "try it" on the Mockbird landing page, or from a terminal:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -d '{"name":"nuxt-demo","preset":"ecommerce"}'
# β†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123"}

The ecommerce preset seeds products, orders, customers and reviews with realistic data β€” names, prices, ratings, timestamps. No signup, no config, CORS on by default. Wire it up the Nuxt way β€” an empty runtimeConfig slot filled by an env var:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      apiUrl: '' // filled by NUXT_PUBLIC_API_URL
    }
  }
})
# .env
NUXT_PUBLIC_API_URL=https://mockbird.mockbird.workers.dev/m/abc123

Nuxt maps NUXT_PUBLIC_* env vars onto runtimeConfig.public automatically β€” no restart-time hardcoding, and ship day is just changing the var. (No terminal handy? Every example below also works against the public demo project: https://mockbird.mockbird.workers.dev/m/demo.)

2. Generate the TypeScript types (don't write them)

curl -o app/types/api.types.ts https://mockbird.mockbird.workers.dev/m/abc123/types.ts

Interfaces for every resource, generated from the live schema. ?format=zod gets you Zod schemas instead if you validate at the boundary.

3. Fetch it with useAsyncData β€” and real pagination

Gotcha #1: the obvious tool, useFetch, doesn't expose response headers β€” and real pagination needs X-Total-Count. The fix is useAsyncData + $fetch.raw, which is also the pattern you'll want any time a header matters:

<!-- app/pages/products.vue -->
<script setup lang="ts">
import type { Product } from '~/types/api.types'

const config = useRuntimeConfig()
const route = useRoute()
const page = computed(() => Number(route.query.page ?? '1'))

const { data, error } = await useAsyncData(
  () => `products-page-${page.value}`,
  async () => {
    const res = await $fetch.raw<Product[]>(`${config.public.apiUrl}/products`, {
      query: { _page: page.value, _limit: 6, sortBy: 'price', order: 'desc' }
    })
    return {
      products: res._data ?? [],
      total: Number(res.headers.get('x-total-count') ?? 0)
    }
  },
  { watch: [page] }
)

if (error.value) {
  throw createError({ statusCode: 500, statusMessage: 'The products API failed', fatal: true })
}
</script>

<template>
  <h1>Products ({{ data?.total }})</h1>
  <ul>
    <li v-for="p in data?.products" :key="p.id">{{ p.name }} β€” ${{ p.price }}</li>
  </ul>
  <NuxtLink v-if="data && page * 6 < data.total" :to="`?page=${page + 1}`">
    Next page β†’
  </NuxtLink>
</template>

Three things are doing real work here: the key is a function of the page (so each page caches separately), watch: [page] refetches on client-side navigation (clicking "Next page" swaps in page 2 without a reload β€” we clicked it), and the pagination is honest: X-Total-Count reflects the live record count, so with 30 seeded products and _limit=6 the "Next page" link genuinely disappears on page 5 (we checked). Sorting, range filters (?price_gte=100) and full-text ?search= work the same way.

4. The parts Nuxt makes special: error.vue and skeletons you can trigger

Nuxt gives you file conventions for the states everyone forgets to test β€” but with a hard-coded array or an instant local stub, your skeleton flashes by in 0ms and error.vue never renders at all. Mockbird misbehaves on demand.

Error page. The createError({ ..., fatal: true }) in the load above renders your app-level app/error.vue:

<!-- app/error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{ error: NuxtError }>()
</script>

<template>
  <h1>{{ error.statusCode }}</h1>
  <p>{{ error.statusMessage }}</p>
  <button @click="clearError({ redirect: '/products' })">Try again</button>
</template>

Trigger it with zero code changes by appending &mock_status=503 to the URL your fetch calls β€” the mock returns a real 503 and your error page renders, with the real status code preserved end to end (we ran exactly this; the SSR response itself came back HTTP 503). Every status 200–599 works, so you can rehearse the whole matrix: 401 β†’ login redirect, 404 β†’ not-found copy, 500 β†’ apology page.

Gotcha #2 (debugging aside): if you curl-test the error path and get Nitro's JSON error blob instead of your error.vue HTML, that's not a bug β€” Nitro content-negotiates. Send -H "Accept: text/html" like a browser does and your error page renders.

Skeletons. The canonical move is useLazyFetch β€” but here's gotcha #3, and it bites everyone: on a hard page load, lazy: true only skips client-side navigation blocking. The server still awaits the fetch during SSR, so your skeleton never paints. In our run the SSR shell took 2,320ms with plain useLazyFetch β€” and 225ms after adding server: false:

<!-- app/pages/reviews.vue -->
<script setup lang="ts">
import type { Review } from '~/types/api.types'
const config = useRuntimeConfig()

const { data: reviews, status } = useLazyFetch<Review[]>(
  `${config.public.apiUrl}/reviews`,
  { query: { _limit: 3, mock_delay: 2000 }, server: false }
)
</script>

<template>
  <h1>Reviews</h1>
  <ul v-if="reviews">
    <li v-for="r in reviews" :key="r.id">{{ r.rating }}β˜… β€” {{ r.body }}</li>
  </ul>
  <p v-else-if="status === 'error'">Couldn't load reviews.</p>
  <p v-else class="skeleton">Loading reviews…</p>
</template>

With ?mock_delay=2000 the API takes a real 2 seconds, so you can finally see the skeleton: in our browser run the shell painted at 225ms and the reviews swapped in at ~3.5s. Combine with ?mock_chaos=0.3 to make a third of requests fail randomly and watch your error branch earn its keep β€” that's a whole guide of its own.

5. The Nuxt-iest trick: a one-line Nitro proxy

Want your app to call /api/mock/... relative URLs instead of the mock's host β€” so the browser never sees the mock URL, and swapping backends later touches literally one line? Nitro's routeRules does it in config:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/api/mock/**': { proxy: `${process.env.NUXT_PUBLIC_API_URL}/**` }
  }
})
curl "http://localhost:3000/api/mock/products?_limit=2"   # β†’ 2 products, proxied
curl -X POST http://localhost:3000/api/mock/products \
  -H "content-type: application/json" \
  -d '{"name":"Proxied product","price":42.5}'            # β†’ 201, and it PERSISTS

Both verified in our run β€” the POST went through the proxy, and fetching the record straight from the mock returned it. Because the mock is a real database, not a canned response: writes stick, and the redirected list shows them. Broke the dataset while experimenting? Re-seed from the dashboard, or save a snapshot first and restore it in your E2E beforeEach β€” every test worker can even pin its own snapshot with one header.

What about server/api fixtures or MSW?

Both are good tools; here's the honest split.

Hand-rolled server/api fixtures (an server/api/products.get.ts returning JSON) are the zero-dependency move, and for one endpoint they're fine. But now you're writing the mock: pagination, filtering, sorting, persistence and error simulation are all code you maintain, the data resets on every restart, and it only exists while your dev server runs β€” the teammate building the mobile app or the backend gets nothing.

MSW is excellent for unit tests β€” but in Nuxt the mock has to exist twice (a browser service worker and a Node interceptor wired into Nitro for SSR fetches), handlers are code you maintain, and state resets per process. A hosted mock sidesteps the whole problem: useAsyncData just fetches a URL β€” same data server-side, client-side, in Playwright, and on your teammate's machine. More in our MSW comparison.

server/api fixturesMSWMockbird
SSR + client fetchesβœ” (relative URL)Two setups (worker + Node)βœ” plain URL (or the routeRules proxy)
Pagination / filters / sortYou write themYou write themBuilt in
Data persistenceResets on restartResets per processPersists, shared with team + CI
Delay / error / chaos injectionYou write itYou write it?mock_delay / ?mock_status / ?mock_chaos
Usable outside your dev serverβ€”β€”curl, Postman, mobile, deployed previews, teammates
Where it winsZero deps, fully offlineOffline unit tests, intercepts the real URLβ€”

6. Ship day: swap the env var

Mockbird follows plain REST conventions, so moving to the real backend is changing NUXT_PUBLIC_API_URL. Nothing else changes β€” the pagination keeps working if your real API sends X-Total-Count too. If the backend team wants a contract to build against, hand them the mock's live spec β€” https://mockbird.mockbird.workers.dev/m/abc123/openapi.json β€” or the generated Postman collection.

The same flow works for Vue (SPA), React, Next.js, SvelteKit, Angular, Flutter and React Native / Expo. Free tier: 20 projects, 10k requests/project/day, no signup. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, reviews…) in the dashboard β€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.