The real problem
I believe every frontend team has suffered from this: the card is ready to start, but the backend route does not exist yet. The most obvious path would be to mock the response directly in the component. We could use a useEffect like:
if (DEV) setData({...})
Creating an object to return what we need. Or, we could just build the entire frontend flow statically and later consume whatever rules the backend returns.
We cannot deny that in some cases this really does work. However, since we are talking about software development, when we have certain patterns and follow them, our lives get much easier. The big problem with the mocking approach I mentioned above is this: the code gets very messy; we can run into certain structural problems when consuming the API. If the task was not well defined at the start, it can end up becoming technical debt, a mock forgotten inside the component, the contract with your backend may change, and other headaches.
On our team, this was a recurring bottleneck. We decided to attack the root: mock outside the component, in a centralized and typed way that turns itself off in production.
The context of our flow
Before we write a single line of code, we have a contract meeting with the backend. In it, we define everything: requests, methods, URLs, interfaces, payloads, and so on. We leave that meeting with an agreed contract 🤝.
It is exactly this contract that helps us unblock the mock. From the moment we know the content of the response and the flow of what we are going to build, we do not need to wait for the backend to deliver the whole route. We translate the contract into a TypeScript interface and keep developing.
The key point: the component consumes the real route, the normal way. We keep consuming it through Use query/Use mutation, following all of our frontend patterns. The magic happens one layer below. When the backend finishes the implementation, we just turn off an environment variable and keep consuming, without having to touch the component.
How it works: an interceptor in Axios
We ended up using an interceptor in Axios itself. It intercepts the request before it goes to the network, and if that URL has a registered mock, it returns the whole scenario in its place.
Notice the guard: the mock only runs if three conditions are true at the same time. If any of them fails, the request goes to the network as usual. That is what keeps the system safe.
Safety: tree-shaking and zero weight in production
This was one of our biggest concerns, because we did not want to risk this mock existing in production.
The import.meta.env.DEV condition becomes the literal false in the production build. With that, Rollup removes the entire interceptor body during tree-shaking. Because of the cascade, the transitive imports disappear along with it. Not a single line of mock code ships to production. We do not bloat the main bundle with code that will never run in prod.
Layers of protection:
-
import.meta.env.DEV→falsein the build → Rollup drops the code. -
MODE !== 'test'→ turns the interceptor off in Vitest (tests use MSW). -
VITE_ENABLE_MSW→ starts asfalsein the versioned.env.
The structure, from above
Everything is organized by feature, without spreading it across the project:
-
interfaces/: the TypeScript interface is the single source of truth for the contract. If the backend changes the response, the error shows up in a single place. -
scenarios.ts: a base constant plus variations through spread (default, empty, error, A/B...). The default is always the happy path we call. But we can pick a specific scenario for that mock and swap the property. -
devHandlers.ts: the central registry. To wire up a new feature, it is one import and one entry in the object.
(💡 As a bonus, the same scenarios are reused in the unit tests (via MSW), so dev and test never fall out of sync.)
The cherry on top: a skill to generate mocks 🍒
Instead of relying on copying and pasting bits of mocks, we built a skill that creates the mock end to end interactively: you provide the feature, the ID, the URL, the method, and you paste the interface. On top of that, the skill also asks you a few questions to confirm the mock, and if it finds something wrong with the interface or the route you gave, it challenges your decision (just like the grill-me skill). It generates the interface, the scenarios, the devMocks.ts, registers it in devHandlers.ts, and runs the type-check. Onboarding a new mock became a matter of a few commands.
The wins 🏆
- The frontend is no longer held hostage by the backend; it unblocks as soon as the contract is agreed.
- Clean code: zero mocks inside components.
- Typed end to end; contract drift breaks at a single point.
- Zero weight in production and zero new dependencies.
- Scenarios reused between dev and tests.
The caveats (because it is not a silver bullet)
To be honest, this does not solve every pain. It speeds up development, but:
- The contract is manual. Only the alignment with the backend and the code review guarantee that the interface matches reality. That is why the contract meeting is non-negotiable.
- Mocked requests do not show up in the Network tab (the adapter resolves before the network), so we debug through the console logs.
- Switching scenarios is a local edit that should never be committed (
masteralways points todefault). - And most important: the goal is to go back to consuming the real route as soon as possible. If something needs to change, the agreement with the backend already exists, so we just adjust the contract on both sides.
Conclusion
The idea is not to run away from the backend, it is to stop waiting for it. With the contract agreed, a lightweight interceptor, and the guarantee that none of this reaches production, the frontend moves on its own and meets the real route at the end, with no rework and no hacks in the component.
So far it has been a great solution that helped my team accelerate development a lot, and it is already being used by other teams as well.

Top comments (0)