Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

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 desig

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

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 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))
    }
  },
},
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

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.