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.
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.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.)
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.
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.
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.
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.
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 fixtures | MSW | Mockbird | |
|---|---|---|---|
| SSR + client fetches | β (relative URL) | Two setups (worker + Node) | β plain URL (or the routeRules proxy) |
| Pagination / filters / sort | You write them | You write them | Built in |
| Data persistence | Resets on restart | Resets per process | Persists, shared with team + CI |
| Delay / error / chaos injection | You write it | You write it | ?mock_delay / ?mock_status / ?mock_chaos |
| Usable outside your dev server | β | β | curl, Postman, mobile, deployed previews, teammates |
| Where it wins | Zero deps, fully offline | Offline unit tests, intercepts the real URL | β |
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.