Hedronite · Frontend Dev · TypeScript · 2026-10-14

Sharing types between a React app and a Hono API on one origin

Export the server's type, build the client from it, and let the browser see one origin.

Lesson Class: Dev
Stack: Vite 8 · React 19 · Hono 4 on Node 26 · TypeScript 6
Source: Hono and Vite docs · Programming TypeScript (Cherny), ch. 9
The app is the contract
Chain the routes, give every c.json a status, export typeof app.
Import the type, not the server
hc<AppType>('/') with import type keeps server code out of the browser.
One origin
Vite proxies /api in development; Hono serves dist/ with a fallback in production.

The problem

You have a React front end and an API, both written in TypeScript. The server returns { notes: [...] }. One afternoon someone renames that field to items because it reads better, and nothing complains. The server's tests pass and the React code still compiles. The first sign of trouble is a blank page in the browser, where data.notes is undefined and the render crashes. Both halves were type-checked, but each against its own private idea of what the other half sends. No compiler checks across the network between them.

Two more pains come along with this one. In development the front end runs on one port (a dev server that rebuilds the page as you edit) and the API runs on another. The browser treats those as two different origins and applies CORS (cross-origin resource sharing), its rule that a page may only read responses from another origin if that origin explicitly allows it. So you end up writing permission headers just to make your own app talk to itself. In production you want one thing to deploy, not a static file host and an API host that have to be kept in step by hand.

The idea, in plain words

Write the API once in TypeScript and export the type of the finished app. The front end builds its HTTP client from that type, so the compiler checks every call against the real server code instead of against a copy someone typed by hand. Then make the browser see only one origin. In development, Vite's dev server forwards every request whose path starts with /api to the API process. In production, one Node process answers /api/* itself and serves the built React files for everything else. Rename a field on the server and the React file that read the old name fails to compile. The whole app ships as a single process.

Concept A: the Hono app is the contract

Hono is a TypeScript web framework. You register routes, each a method plus a URL pattern, and attach a handler function to each one. The handler receives a context object, conventionally called c, which reads the request and builds the response. Hono's RPC feature (remote procedure call: invoking something on the server as if it were a local function) "allows sharing of the API specifications between the server and the client." Here the contract is the Hono app itself: one description of what the API accepts and returns, which both sides check against.

The tested setup was macOS with Node 26.8.2, Bun 1.4.2 (used only as package installer and script runner, never as the server), Vite 8.3.4, React 19.3.1, TypeScript 6.0.3, hono 4.13.13, @hono/node-server 2.1.5, zod 4.6.7 and @hono/zod-validator 0.9.1.

bun create vite web --template react-ts && cd web && bun install
bun add hono @hono/node-server zod @hono/zod-validator

Why a contract at all. Programming TypeScript points out that even when a client and a server are each fully typesafe, they still talk over an untyped protocol such as HTTP. If you cannot share the request type between them, the two drift out of sync over time. Tools like Swagger or gRPC fix this by generating both sides from a common source of truth. Hono RPC is the case where both sides are TypeScript, so the server code is that source of truth and no generation step is needed.

Four details make the contract precise, and each has a reason:

Here is the whole API:

// server/app.ts
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import * as z from 'zod'

type Note = { id: number; text: string }
const notes: Note[] = [{ id: 1, text: 'first note' }]

export const app = new Hono()
  .basePath('/api')
  .get('/notes', (c) => c.json({ notes }, 200))
  .post(
    '/notes',
    zValidator('json', z.object({ text: z.string().min(1).max(200) })),
    (c) => {
      const { text } = c.req.valid('json')
      const note = { id: notes.length + 1, text }
      notes.push(note)
      return c.json({ note }, 201)
    },
  )
  .get('/notes/:id', (c) => {
    const note = notes.find((n) => n.id === Number(c.req.param('id')))
    if (!note) return c.json({ error: 'not found' }, 404)
    return c.json({ note }, 200)
  })

export type AppType = typeof app

basePath('/api') prefixes every route, so /notes is really /api/notes. The POST route puts zValidator('json', ...) in front of the handler, which means the handler only runs when the body parses as JSON with a text string of 1 to 200 characters. Inside the handler, c.req.valid('json') returns that checked value with the right type. The handler answers c.json({ note }, 201), where 201 means "created." The last route answers a missing note with c.json({ error: 'not found' }, 404), so the error has a typed shape of its own. The final line exports the type and nothing else.

Starting the finished server and calling it with curl shows the runtime behavior behind those types:

$ curl -i localhost:8787/api/notes
HTTP/1.1 200 OK
{"notes":[{"id":1,"text":"first note"}]}

$ curl -i -X POST localhost:8787/api/notes -H 'content-type: application/json' -d '{"text":"from curl"}'
HTTP/1.1 201 Created
{"note":{"id":2,"text":"from curl"}}

$ curl -i -X POST localhost:8787/api/notes -H 'content-type: application/json' -d '{"text":""}'
HTTP/1.1 400 Bad Request
{"success":false,"error":{"name":"ZodError", ... "message":"Too small: expected string to have >=1 characters" ...}}

$ curl -i -X POST localhost:8787/api/notes -d '{"text":"x"}'
HTTP/1.1 400 Bad Request

$ curl -i localhost:8787/api/notes/99
HTTP/1.1 404 Not Found
{"error":"not found"}

The first call returns the seed note with a 200. The second creates note 2 and returns 201 with the new note. An empty text fails the min(1) rule, so zod's error comes back with a 400 before the handler runs. The fourth call sends valid JSON but no content-type header. Hono's validation docs warn that without a matching header the body is not parsed and the validator receives an empty object {}, which fails the schema, so you get a 400 again. That one is easy to misread as a bug in your data. The last call shows the typed 404 body.

Common mistake. Returning c.notFound() for a missing note. The docs say that if you use a client, you should not use it for the Not Found response, because the data the client gets "cannot be inferred correctly." You spot it on the client side: the error branch has no { error: string } shape to read, even though the server clearly sends a 404. Return c.json({ error: 'not found' }, 404) instead, as the lab does.

Concept B: the typed client and the type-only import

The client is a small object that turns property accesses into HTTP calls. Hono provides hc (Hono Client). You hand it the app's type as a generic parameter, which is TypeScript's angle-bracket syntax for passing a type into a function. By accepting AppType, the client "can infer both the input type(s) specified by the Validator, and the output type(s) emitted by handlers returning c.json()." The input type is what the zod schema accepts. The output types are the shapes you returned, one per status code.

// src/api.ts
import { hc } from 'hono/client'
import type { AppType } from '../server/app.ts'

export const client = hc<AppType>('/')

This is a type-only import, an import type statement that can bring in only types, never runtime values. The reason matters. The server file pulls in zod, the validator and the in-memory notes array, and none of that belongs in a browser. Programming TypeScript observes that when an import is used only for its type, "TypeScript compiled away the static import." Writing import type makes that guarantee explicit, so the compiler reads the server file for its types and then erases the import from the output. Without this you would hand-write fetch calls and cast results with as Note[], and TypeScript would trust each cast blindly. The client's base URL, '/', means "the same origin that served this page," which is what lets one build work in both dev and production.

Now the React component that uses the client:

// src/App.tsx
import { useEffect, useState } from 'react'
import { client } from './api.ts'

type Note = { id: number; text: string }

export default function App() {
  const [notes, setNotes] = useState<Note[]>([])
  const [text, setText] = useState('')
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    client.api.notes
      .$get()
      .then((res) => res.json())
      .then((data) => setNotes(data.notes))
  }, [])

  async function addNote() {
    const res = await client.api.notes.$post({ json: { text } })
    if (res.status === 201) {
      const { note } = await res.json()
      setNotes((prev) => [...prev, note])
      setText('')
      setError(null)
    } else {
      setError(`server said ${res.status}`)
    }
  }

  return (
    <main>
      <h1>Notes</h1>
      <ul>
        {notes.map((n) => (
          <li key={n.id}>{n.text}</li>
        ))}
      </ul>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={addNote}>Add</button>
      {error && <p role="alert">{error}</p>}
    </main>
  )
}

The path client.api.notes mirrors the URL /api/notes, and $get() and $post() pick the HTTP method. The returned res is compatible with the browser's standard fetch Response, so res.json() works as you already know it, except that its result is typed. In the effect, data.notes is known to be an array of notes because the GET handler returned { notes }. In addNote, the json: { text } option must match the zod schema, and the code branches on res.status === 201. Because the server declared that status explicitly, the success branch can destructure note from the body. Any other status falls into the else and shows server said 400, for example, in the alert paragraph.

The payoff comes when the server changes. Edit the GET handler to c.json({ items: notes }, 200) and run the type checker:

$ bunx tsc -b
src/App.tsx(15,37): error TS2339: Property 'notes' does not exist on type '{ items: { id: number; text: string; }[]; }'.

The error points at line 15, column 37 of the React file, which is the data.notes access, even though you edited the server. The message even prints the new shape, so the fix is obvious: either rename the access or revert the server change. After reverting the server line, tsc -b passes again. The project's build script is tsc -b && vite build, so this check runs on every build and a drifted contract stops the build before anything ships. For the types to flow properly across the two sides, the Hono docs require "strict": true in both tsconfig files. Here tsconfig.app.json covers src, tsconfig.node.json covers server and vite.config.ts, and both set it.

Common mistake. Running different Hono versions on the client and server side. The docs warn this can produce the error "Type instantiation is excessively deep and possibly infinite." You spot it as that message showing up in the editor on the hc<AppType> line, with no mistake in your own code. Keep the client and the server on the same hono version.

Concept C: one origin in dev and in production

An origin is the combination of scheme, host and port, such as http://localhost:5173. Two URLs with different ports are different origins, which is why a page at port 5173 calling an API at port 8787 triggers CORS. A proxy is a middleman server that receives a request and forwards it elsewhere. If the dev server proxies API requests, the browser only ever talks to port 5173, so there is one origin and no CORS configuration to write. That is the reason to use it. Otherwise you would add cross-origin headers to the API purely to serve a development setup.

Vite's server.proxy option does this. Per the docs, "any requests whose request path starts with that key will be proxied to the specified target," and a string value is shorthand for the target address.

// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:8787',
    },
  },
})

The key '/api' is the path prefix to match, and the value is where matching requests are sent. The string form keeps the path unchanged, so /api/notes on port 5173 arrives as /api/notes on port 8787. Everything else, such as / and the React source files, is served by Vite itself. With the Hono server running in one terminal and bunx vite --port 5173 in a second, requests to port 5173 reach the API:

$ curl -i localhost:5173/api/notes
HTTP/1.1 200 OK
{"notes":[{"id":1,"text":"first note"}]}

$ curl -i -X POST localhost:5173/api/notes -H 'content-type: application/json' -d '{"text":"through the proxy"}'
HTTP/1.1 201 Created
{"note":{"id":2,"text":"through the proxy"}}

These are the same responses you got from port 8787, now obtained through the Vite port. If you stop the Hono server but leave Vite running, the same GET returns 502 Bad Gateway, and the Vite terminal prints [vite] http proxy error: /api/notes AggregateError [ECONNREFUSED]. That message means the proxy worked and the target was not listening, so start the API process rather than debugging your React code.

The Vite docs also say that, unless noted, the options in that section "are only applied to dev." The proxy does nothing in production, so production needs something else to put everything behind one origin. First, vite build takes index.html as its entry point and produces output "suitable to be served over a static hosting service," which lands in dist/. The build printed dist/index.html, one CSS file and one JS file of about 225 kB. Second, the Hono server must serve those files itself.

// server/index.ts
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { app as api } from './app.ts'

const root = new Hono()
root.route('/', api)
root.use('/*', serveStatic({ root: './dist' }))
root.get('*', serveStatic({ path: './dist/index.html' }))

const port = Number(process.env.PORT ?? 8787)
serve({ fetch: root.fetch, port }, (info) => {
  console.log(`listening on http://localhost:${info.port}`)
})

Run it with node server/index.ts. Node 26 executes the TypeScript file directly, which is why the import of ./app.ts spells out its extension. The server prints listening on http://localhost:8787. The order of registration is the point of the file. root.route('/', api) mounts the API first, so /api/... requests reach the API handlers before any wildcard can claim them. Next, serveStatic({ root: './dist' }) serves real files such as the JS and CSS assets. The last line is the SPA fallback. An SPA (single-page application) is an app where the browser loads one HTML page and JavaScript then handles navigation, so URLs like /some/client/route exist only in the browser's router. If a user refreshes on such a URL, the server is asked for a file that does not exist, and the fallback answers with dist/index.html so the app can start and take over routing. Requesting localhost:8787/some/client/route returns 200 OK with a body containing <div id="root"></div>. The path option serves exactly one file, which is what the fallback needs. serve comes from @hono/node-server, and its fetch: root.fetch line hands every incoming request to the Hono app.

Common mistake. Starting the server from the wrong directory. The docs state that the root option "resolves paths relative to the current working directory (process.cwd())," so ./dist means the dist folder next to wherever you ran node. Started from /tmp instead of the project folder, the lab server still answered /api/notes with 200, but / returned 404 Not Found, and the server log printed serveStatic: root path './dist' is not found, are you sure it's correct? The API needs no files, so only the static part breaks. Run the command from the project folder, where dist/ lives.

Putting it together

Follow one action through the whole system: a user types "buy milk" and clicks Add.

  1. addNote calls client.api.notes.$post({ json: { text } }). At compile time TypeScript already confirmed that text is a string, because the schema on the server says so. In the tested hono 4.13.13 the client also sets Content-Type: application/json on its own when you pass json, so the missing-header 400 from Concept A only affects hand-written requests such as the curl call.
  2. In dev, the browser sends a POST to localhost:5173/api/notes. Vite sees the /api prefix, forwards the request to localhost:8787, and relays the answer. In production, the browser sends the same POST to the single Node process, which has no forwarding step at all.
  3. Hono routes the request to the POST handler under /api/notes. Zod validates the JSON body. A valid body reaches the handler, which stores the note and returns c.json({ note }, 201). An empty text is stopped with a 400 before the handler runs.
  4. Back in React, res.status === 201 takes the success branch, appends the note to state and clears the input. A 400 takes the else branch and renders server said 400 in the alert paragraph.

Now return to the problem. If someone renames notes to items on the server, tsc -b fails at the exact React line that still reads data.notes, and the build script stops before vite build runs. The two ports and the CORS headers never come up, because dev has a proxy and production has no second origin. The deployable is one node server/index.ts process plus the dist/ folder next to it.

Check yourself

  1. **Why does src/api.ts use import type { AppType } instead of a normal import?** The import is used only for its type, so TypeScript erases it. Server code such as zod and the notes array never reaches the browser, while the client still gets the full contract.
  1. **The server's 404 branch uses c.json({ error: 'not found' }, 404). What goes wrong with c.notFound()?** The client cannot infer the response data correctly, so the error shape is missing from the client's types.
  1. **A POST to /api/notes with valid JSON but no content-type header returns 400. Why?** Hono only parses a JSON body when the matching Content-Type: application/json header is present. Otherwise the validator receives {}, which fails the schema.
  1. **Your proxy config works in dev, but a deployed build returns nothing for /api/notes. What did you forget?** server.proxy only applies to the dev server. In production the Node process must serve /api itself, by mounting the Hono API in the same server that serves dist/.
  1. **A user refreshes the browser on /some/client/route and gets a 404 from a plain static server. What fixes it, and where is it in the lab?** An SPA fallback that returns index.html for unknown paths. In the lab it is root.get('*', serveStatic({ path: './dist/index.html' })), registered after the API and the static files.

Sources