DEV Community

Cover image for Mock Your API Inside the Client: HTTP, SSE, NDJSON & WebSocket with tanstack-fetch
Mohammad Garmabi
Mohammad Garmabi

Posted on Originally published at mohamadgarmabi.github.io

Mock Your API Inside the Client: HTTP, SSE, NDJSON & WebSocket with tanstack-fetch

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:

  1. A REST endpoint — market overview
  2. A live SSE feed — price ticks
  3. A streaming NDJSON endpoint — an AI market summary
  4. A WebSocket room — trader chat
  5. Errors and latency
  6. Switching to the real API, one route at a time

Stack: tanstack-fetch/live · any framework · optional @tanstack/react-query

npm install tanstack-fetch
Enter fullscreen mode Exit fullscreen mode

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,
  }),
})
Enter fullscreen mode Exit fullscreen mode

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 },
  ],
})
Enter fullscreen mode Exit fullscreen mode
// 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 },
]
Enter fullscreen mode Exit fullscreen mode

Your component code is exactly what ships to production:

useQuery({
  queryKey: ['market', 'overview'],
  queryFn: ({ signal }) => api.get<MarketOverview>('/market/overview', { signal }),
})
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{ path: '/market/ticks', sse: true, handler: streamMarketTicksMock },
Enter fullscreen mode Exit fullscreen mode

Subscribe the usual way:

const { data: tick, status } = useSse<Tick>('/market/ticks', { query: { symbol: 'BTC' } })
Enter fullscreen mode Exit fullscreen mode

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 the sse: true route; 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))
    }
  },
},
Enter fullscreen mode Exit fullscreen mode
let text = ''
for await (const { token } of api.stream<{ token: string }>('/market/summary', {
  body: { symbol: 'BTC' },
})) {
  text += token
  setSummary(text)
}
Enter fullscreen mode Exit fullscreen mode

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)
  },
},
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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 }),
  },
]
Enter fullscreen mode Exit fullscreen mode

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 },
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode
// 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 })
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (0)