HSHSKY Lab
workflows8 min read

My Demo Mode Almost Needed a Whole New Backend — Until a Tiny Axios Adapter Fixed It

I wanted one frontend codebase that works as a fully static demo and against a real API. The first two architectures I asked Claude Code to evaluate would have quietly broken my zero-backend deploy — the third one, a custom axios adapter, didn't.

In-Article Ad — Replace with ad code after approval

I'm building an admin dashboard for an education platform — courses, course objectives, that kind of thing. It ships two ways: as a fully static site on Cloudflare for sales demos (DEMO_MODE=true, no backend at all), and as the real app talking to a FastAPI backend. Over time that split had leaked into the components. About seven pages had grown their own

if (DEMO_MODE) {
  // hardcoded data
} else {
  // real api.get(...) call
}

branch, each with its own copy of the data shape. I wanted to collapse that into one code path — one api.get('/admin/courses') call that works in both modes. So I described the idea to Claude Code and asked it to think through the architecture before I touched any code.

The Idea: One API, Two Modes

My first pitch was simple: both modes hit the same endpoint, and a parameter decides whether you get canned JSON or a real database row. Same URL, same response shape, switch by a flag. I asked Claude Code to analyze it.

Option A: Let the Backend Decide

The first read: have each endpoint branch on a ?mock=true query param and return hardcoded data without touching the database.

  • Upside: the frontend becomes completely uniform — no if (DEMO_MODE) anywhere, one mapper, one type.
  • Downside: I had roughly five backend modules and twenty-plus endpoints. Every one of them would need a mock branch (or a shared middleware that intercepts by path). And the bigger problem — demo mode would now require a running FastAPI process, even if it never touched Postgres. That quietly kills the "fully static, zero backend" Cloudflare deploy I currently have.

Option B: Push It to the Edge

The second option kept the "one URL" property without resurrecting the backend in demo mode: let Cloudflare Pages Functions intercept /api/* and return canned JSON in demo, and only proxy to the real API in production.

  • Upside: still one URL, and demo stays backend-free.
  • Downside: there was no edge-functions infrastructure in the project at all. I'd be building a whole new layer — routing, fixtures, deploy config — from scratch, for every endpoint, just to support a demo toggle.

Both options were project-wide changes (not just the one page I was working on), and both either broke the deploy model or required new infrastructure. I went back to the drawing board.

Round Two: Same Idea, a Different Base URL

My second pass: keep the data source as "an API call" in the code, but point demo mode at a different base URL — /api/test instead of /api — where the mock endpoint just returns hardcoded JSON.

Claude Code's analysis this time went one layer deeper: an axios request interceptor could tag requests in demo mode, paired with a backend middleware that checks for that tag and short-circuits to a fixture before hitting the router or the database. Reads would return canned data; writes (POST/PUT/DELETE) would return a fixed "success" response without persisting anything.

It was workable — but it had the same shape as Option A's core problem. Demo mode still meant a FastAPI process had to be running, even if it was fixture-only and never opened a database connection. The deploy model still changed.

The Idea That Actually Won

Third try, and this is the one that stuck: what if DEMO_MODE=true means /api/... calls never leave the browser at all? The frontend itself returns the hardcoded data, shaped exactly like the real response. DEMO_MODE=false makes the exact same call against the real backend. Same call sites, same data shape, zero backend either way in demo.

Claude Code's read: this was the best of the three by a clear margin —

  • The interception happens at the axios layer, via a custom adapter. No backend changes, no edge functions. The existing static Cloudflare deploy is untouched.
  • Axios (1.6.7, already a dependency) supports a custom adapter natively — no need for axios-mock-adapter or MSW.
  • As long as the mock data is shaped like the real response — which it already was for the page I was working on — every page can just write await api.get('/admin/courses') and never branch on DEMO_MODE again.

So we built it.

Building It

The whole mechanism is a route table plus a custom AxiosAdapter. Register handlers the same way you'd define backend routes, with :param segments and method verbs:

// frontend/src/api/mockAdapter.ts
import type { AxiosAdapter, AxiosResponse } from 'axios'

type MockHandler = (params: Record<string, string>, body: unknown) => unknown

interface MockRoute {
  method: string
  regex: RegExp
  paramNames: string[]
  handler: MockHandler
}

const routes: MockRoute[] = []

function compile(path: string): { regex: RegExp; paramNames: string[] } {
  const paramNames: string[] = []
  const regexSource = path
    .split('/')
    .map(segment => {
      if (segment.startsWith(':')) {
        paramNames.push(segment.slice(1))
        return '([^/]+)'
      }
      return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    })
    .join('/')
  return { regex: new RegExp(`^${regexSource}/?$`), paramNames }
}

function register(method: string, path: string, handler: MockHandler) {
  const { regex, paramNames } = compile(path)
  routes.push({ method: method.toUpperCase(), regex, paramNames, handler })
}

export const mockGet = (path: string, handler: MockHandler) => register('GET', path, handler)
export const mockPost = (path: string, handler: MockHandler) => register('POST', path, handler)
export const mockPut = (path: string, handler: MockHandler) => register('PUT', path, handler)
export const mockDelete = (path: string, handler: MockHandler) => register('DELETE', path, handler)

export const mockAdapter: AxiosAdapter = async (config) => {
  const method = (config.method ?? 'get').toUpperCase()
  const path = (config.url ?? '').split('?')[0]

  let body: unknown = config.data
  if (typeof body === 'string') {
    try {
      body = JSON.parse(body)
    } catch {
      // not JSON — pass through as-is
    }
  }

  for (const route of routes) {
    if (route.method !== method) continue
    const match = path.match(route.regex)
    if (!match) continue
    const params: Record<string, string> = {}
    route.paramNames.forEach((name, i) => { params[name] = decodeURIComponent(match[i + 1]) })
    const data = route.handler(params, body)
    return {
      data,
      status: 200,
      statusText: 'OK',
      headers: {},
      config,
    } as AxiosResponse
  }

  return Promise.reject(new Error(`[mockAdapter] no mock route for ${method} ${path}`))
}

That's the entire mechanism — about 75 lines. No new dependencies, no codegen.

Wiring It In

The axios instance only picks up the adapter when DEMO_MODE is on; otherwise it's undefined and axios falls back to its default XHR/fetch behavior:

// frontend/src/api/index.ts
import axios from 'axios'
import { DEMO_MODE } from '../config'
import { mockAdapter } from './mockAdapter'
import './mockRoutes'

const api = axios.create({
  baseURL: '/api',
  headers: { 'Content-Type': 'application/json' },
  ...(DEMO_MODE ? { adapter: mockAdapter } : {}),
})

export default api

./mockRoutes is a side-effect import — a folder of modules that each call mockGet/mockPost/etc. to register their routes against the shared table before the first request goes out.

Rolling It Out

I migrated one page first — the one whose mock data was already shaped like the real backend response, so the mapper code didn't have to change at all. Removing its if (DEMO_MODE) branch and registering a mockGet route for the same path was close to a no-op.

The second page needed full CRUD, not just a read. For that, the mock route module keeps a mutable in-memory copy of the seed data, so create/edit/delete "stick" for the rest of the session even though nothing is persisted:

let coursesStore: ApiCourse[] = mockCourses.map(c => ({ ...c }))

mockGet('/admin/courses', () => coursesStore)

mockPost('/admin/courses', (_params, body) => {
  const created: ApiCourse = { id: `mock-${Date.now()}`, ...(body as Partial<ApiCourse>) }
  coursesStore.push(created)
  return created
})

Five more pages still have the old if (DEMO_MODE) branches — they're next, one at a time, now that the pattern is proven.

Tip

The whole thing only works because the mock data is shaped identically to the real API response. If your fixtures and your backend schema drift apart, you're back to per-page branching — just hidden inside the mock data instead of the component.

FAQ

Why not axios-mock-adapter or MSW?

Axios has supported a custom config.adapter for a long time — you don't need a library to intercept requests, just a function with the right shape. The route-table version above is ~75 lines, fully readable, and has zero behavior you didn't write yourself.

What happens to POST/PUT/DELETE in demo mode?

They go through the same route table. A handler can either return a fixed "success" response, or — like the example above — mutate an in-memory array so the demo feels real (create a course, see it in the list, refresh-safe for the session, gone on reload).

Bottom Line

The architecture that won wasn't my first idea or my second — it was my third, after two rounds of having Claude Code pressure-test each one against a constraint I hadn't stated up front but cared about a lot: keeping the demo build fully static. Once that constraint was explicit, the right answer — intercept at the axios layer, never leave the browser — was almost obvious. The hard part wasn't writing the adapter; it was finding the one design where there was nothing left to build.

In-Article Ad — Replace with ad code after approval

Tags

claude codereactaxiostypescriptarchitecture
H

Written by HSKY

Developer writing about AI coding tools — Claude Code, Cursor, agents, and the workflows that make them work.