Build the whole frontend while the backend is still two weeks out — then swap in the real API one endpoint at a time, without touching a single component.
Originally published on the tanstack-fetch blog.
The design is approved, the sprint has started, and the backend is two weeks behind. Your usual options all have a cost:
- Hard-coded data in components skips the loading, error and retry paths — exactly the parts that break later.
- A separate mock server is one more process to run, and most stop at REST. A live price feed over SSE or a chat over WebSocket? You're on your own.
- Service-worker mocks need extra setup per environment, and streaming support varies.
tanstack-fetch takes a different route: a mocks option that answers requests inside the client. The mock code lives in its own entry, tanstack-fetch/mock (1.82 KB gzip), so apps that don't use it pay nothing. It covers every transport the client speaks — HTTP, SSE, NDJSON streams, WebSocket, upload and tRPC. Your interceptors keep running, and every route has its own on/off switch.
In this tutorial we'll build the data layer of a small trading dashboard:
- A REST endpoint — market overview
- A live SSE feed — price ticks
- A streaming NDJSON endpoint — an AI market summary
- A WebSocket room — trader chat
- Errors and latency
- Switching to the real API, one route at a time
Stack: tanstack-fetch/live · any framework · optional @tanstack/react-query
npm install tanstack-fetch Step 1 — One client, one switch
Put the mocks next to the client you already have:
// src/lib/api.ts import { createFetch } from 'tanstack-fetch/live' import { createMocks } from 'tanstack-fetch/mock' import { routes } from '../mocks' export const api = createFetch({ baseUrl: import.meta.env.VITE_API_URL, getToken: () => localStorage.getItem('token'), mocks: createMocks({ enabled: import.meta.env.VITE_USE_API_MOCK !== 'false', delayMs: 250, // feel the loading states routes, }), }) VITE_USE_API_MOCK=false turns every mock off. Requests that no route matches always go to the real API, so you only mock what's missing.
Step 2 — A REST endpoint
// src/mocks/market.ts import type { MockRequest } from 'tanstack-fetch/mock' export const getMarketOverviewMock = ({ query }: MockRequest) => ({ currency: query.get('currency') ?? 'USD', markets: [ { symbol: 'BTC', price: 67_250, change: 1.8 }, { symbol: 'ETH', price: 3_410, change: -0.4 }, ], }) // src/mocks/index.ts import type { MockRoute } from 'tanstack-fetch/mock' import { getMarketOverviewMock } from './market' export const routes: MockRoute[] = [ { method: 'GET', path: '/market/overview', handler: getMarketOverviewMock }, ] Your component code is exactly what ships to production:
useQuery({ queryKey: ['market', 'overview'], queryFn: ({ signal }) => api.get<MarketOverview>('/market/overview', { signal }), }) The handler receives params (from :id segments), query, the JSON-parsed body, and headers — including the authorization header your getToken added. A mocked request goes through the same interceptors as a real one.
Step 3 — A live SSE feed
Set sse: true and write an async generator. Each value you yield becomes one event:
export async function* streamMarketTicksMock({ query, signal }: MockRequest) { const symbol = query.get('symbol') ?? 'BTC' let price = 67_250 try { while (!signal?.aborted) { price += (Math.random() - 0.5) * 50 yield { symbol, price: Math.round(price), at: Date.now() } await new Promise((resolve) => setTimeout(resolve, 1000)) } } finally { console.log('ticks stream closed') // runs when the client unsubscribes } } { path: '/market/ticks', sse: true, handler: streamMarketTicksMock }, Subscribe the usual way:
const { data: tick, status } = useSse<Tick>('/market/ticks', { query: { symbol: 'BTC' } }) When the component unmounts, the stream closes and the generator's finally block runs — no leaking mock timers.
💡 An SSE route can share a path with a plain GET route.
api.sse('/market/ticks')only matches thesse: trueroute;api.get('/market/ticks')matches the other.
Step 4 — A streaming NDJSON response
AI endpoints often stream JSON lines. A normal route whose handler is an async generator is streamed as NDJSON to api.stream():
{ method: 'POST', path: '/market/summary', handler: async function* ({ body }) { const { symbol } = body as { symbol: string } const words = `${symbol} is trading above its weekly average on strong volume.`.split(' ') for (const word of words) { yield { token: `${word} ` } await new Promise((resolve) => setTimeout(resolve, 80)) } }, }, let text = '' for await (const { token } of api.stream<{ token: string }>('/market/summary', { body: { symbol: 'BTC' }, })) { text += token setSummary(text) } Step 5 — A WebSocket room
WebSocket routes get a tiny server-side connection: send, close and onMessage. Return a function to clean up when the socket closes.
{ ws: true, path: '/rooms/:room', handler: (connection, { params }) => { connection.send({ type: 'system', text: `Welcome to #${params.room}` }) connection.onMessage((message) => { const { text } = message as { text: string } connection.send({ type: 'message', from: 'you', text }) setTimeout(() => connection.send({ type: 'message', from: 'bot', text: `echo: ${text}` }), 400) }) const presence = setInterval( () => connection.send({ type: 'presence', online: 3 + Math.floor(Math.random() * 5) }), 5000, ) return () => clearInterval(presence) }, }, const socket = api.ws('/rooms/:room', { params: { room: 'btc' }, onMessage: (event) => addEvent(event as ChatEvent), }) socket.send({ text: 'gm' }) // queued until open, then delivered to onMessage Want to test reconnects? Call connection.close(1011). Any code other than 1000 makes the client back off and reconnect, which runs your handler again.
Step 6 — Errors and latency
The happy path is the easy part. Return a Response to test the rest:
let calls = 0 export const routes: MockRoute[] = [ // flaky endpoint: first call fails with 503 (retried with plugins: ['retry-idempotent']) { method: 'GET', path: '/portfolio', handler: () => ++calls === 1 ? new Response('Service Unavailable', { status: 503 }) : { total: 12_400, positions: 4 }, }, // rate limit: your onTooManyRequests handler and parseRetryAfter see it { method: 'POST', path: '/orders', delayMs: 800, handler: () => Response.json({ message: 'Slow down' }, { status: 429, headers: { 'retry-after': '2' } }), }, // session expired: your onUnauthorized / refresh-token logic runs { method: 'GET', path: '/me', handler: ({ headers }) => headers.get('authorization') === 'Bearer fresh' ? { name: 'Ada' } : Response.json({ message: 'expired' }, { status: 401 }), }, ] Because mocks replace only the transport, every status handler, retry policy and plugin runs exactly as it would against the real server. You test the code you'll ship.
Step 7 — Switch to the real API, one route at a time
The backend ships /market/overview first. Turn that one mock off and keep the rest:
{ method: 'GET', path: '/market/overview', handler: getMarketOverviewMock, enabled: false }, enabled is read on every request, so you can flip it at runtime — for example from a small dev panel:
routes.forEach((route) => { route.enabled = devPanel.isMocked(route.path) }) When the last route is off, delete the mocks folder. Your components and queries don't change.
Bonus: keep fixtures out of production
A static import puts tanstack-fetch/mock and every fixture in your production bundle, even with enabled: false. Move createMocks into the mocks folder and load it behind the flag so the bundler drops both:
// src/mocks/index.ts import { createMocks } from 'tanstack-fetch/mock' export const mocks = createMocks({ delayMs: 250, routes }) // src/lib/api.ts const mocks = import.meta.env.VITE_USE_API_MOCK === 'true' ? (await import('../mocks')).mocks : undefined export const api = createFetch({ baseUrl, getToken, mocks }) Bonus: reuse the same mocks in tests
Routes are plain objects, so they drop straight into Vitest:
import { createFetch } from 'tanstack-fetch/live' import { createMocks } from 'tanstack-fetch/mock' import { routes } from '../src/mocks' it('renders the overview', async () => { const api = createFetch({ baseUrl: 'https://api.test', mocks: createMocks({ routes }) }) const overview = await api.get('/market/overview') expect(overview.markets).toHaveLength(2) }) Cheat sheet
| Need | Route |
|---|---|
| REST response | { method, path, handler: () => data } |
| Error / custom status | handler: () => new Response(…, { status }) |
| Live events (SSE) | { path, sse: true, handler: async function* } |
| Streaming JSON (NDJSON) | { method, path, handler: async function* } |
| WebSocket | { ws: true, path, handler: (connection) => … } |
| Slow network | delayMs on the route or on mocks |
| Turn one mock off | enabled: false |
| Turn every mock off | createMocks({ enabled: false, … }) |
Links
- 📦 npm: tanstack-fetch
- ⭐ GitHub: mohamadgarmabi/tanstack-fetch
- 📚 Docs: mohamadgarmabi.github.io/tanstack-fetch
- 🧪 Playground: examples/playground
If this saved you from waiting on the backend, a ⭐ on GitHub helps a lot — and I'd love to hear how you mock streaming endpoints today.