A backend route and the frontend call that hits it should never disagree about the shape of the data. Hype Stack closes that loop with a type bridge that reads your Hono routes and produces a fully typed SDK the frontend imports as a single object.
No hand-written request types. No redeclared payloads. The SDK's type is the backend's type, full stop.
On the backend, routes are Hono apps. You declare a path, a validator, and a handler, and Hono infers the request and response types from that declaration. On the frontend, the SDK is built on HyperFetch. Reads go through useFetch(sdk.<path>.$get), mutations through useSubmit(sdk.<path>.$post), and WebSocket events through useListener(socketSdk.<domain>.<event>.$listener).
The bridge between them is generated. The frontend SDK is created from typeof client and the backend's ApiRoutesSdk type, so the moment you add a route on the backend, it shows up on sdk.
import { createSdk } from "@hyper-fetch/core";
import type { ApiRoutesSdk, WsSocketSdk } from "@internal/backend";
import { client, socket } from "./client";
export const sdk = createSdk<typeof client, ApiRoutesSdk>(client);
export const socketSdk = createSocketSdk<typeof socket, WsSocketSdk>(socket);
Change a response shape and every useFetch consuming it flags the mismatch at typecheck time. Rename a path and the call site breaks with a real error, not a 404 at runtime. Add a new endpoint and it is on sdk immediately, with its request and response types carried over for free.
The same applies to sockets. A new backend event appears on socketSdk.<domain>.<event>, and the payload type is whatever the backend emitter declared. You import the payload type directly from the backend package instead of re-exporting it through a listener index file.
Rename a route on the backend and the frontend call fails at typecheck, not as a 404 in production.
HyperFetch caches by endpoint key. After a mutation that affects data another component reads, you invalidate with client.cache.invalidate(/\/invitations\/pending/), using the client import directly. The regex matches every endpoint whose path fits, so every useFetch sharing that cache key refetches on its own.
The one rule that matters: never traverse through an SDK request to reach the cache. sdk.foo.$get.client.cache.invalidate(...) looks like it should work and quietly does the wrong thing. Always go through the client import.
import { client } from "@/api/client";
// Invalidate every endpoint whose path matches after a mutation.
client.cache.invalidate(/\/invitations\/pending/);
Raw useEffect plus .listen() is the default way people wire sockets, and it is where bugs live: stale closures, double subscriptions, listeners that never clean up. The typed socket SDK replaces that with useListener(socketSdk.<domain>.<event>.$listener) and an onEvent callback. Subscribe in one line, unsubscribe automatically, and the payload is typed.
Emitting is symmetric: useEmitter(socketSdk.<domain>.<event>.$emitter) and emit().
One useListener call subscribes, auto-unsubscribes, and infers the payload type straight from the backend emitter.
The alternative is a frontend that knows the backend exists only through string URLs and hand-maintained types. Every endpoint is a leap of faith, and every refactor is a runtime gamble. With the bridge, a typecheck is a contract check. If the build passes, the calls line up.
For a template that ships auth, billing, and notifications across a real frontend and a real backend, that is the difference between a demo and a starting point you can ship. The HTTP data-fetching docs and WebSocket docs cover the exact patterns.