Skip to main content
ZeroStarter uses Hono’s RPC client to achieve complete type safety between your API server and frontend with zero code generation or build steps.

How It Works

The type-safe API client works by:
  1. Exporting the API route type from Hono server
  2. Importing that type in the frontend
  3. Using Hono’s hc (Hono Client) with the type parameter
No OpenAPI specs, no code generation, just TypeScript.

API Server Setup

Defining Routes

The Hono API exports its route structure as a TypeScript type:
api/hono/src/index.ts
import { Hono } from "hono"
import { authRouter, v1Router } from "@/routers"

const app = new Hono()

const routes = app
  .get("/", (c) => {
    const data = { version: BUILD_VERSION, environment: env.NODE_ENV }
    return c.json({ data })
  })
  .basePath("/api")
  .get("/health", (c) => {
    const data = { message: "ok", version: BUILD_VERSION, environment: env.NODE_ENV }
    return c.json({ data })
  })
  .route("/auth", authRouter)
  .route("/v1", v1Router)

export type AppType = typeof routes
The AppType export contains all route information including paths, methods, request/response types.

Protected Routes

Protected routes use middleware to add session context:
api/hono/src/routers/v1.ts
import type { Session } from "@packages/auth"
import { Hono } from "hono"
import { authMiddleware } from "@/middlewares"

export const v1Router = new Hono<{
  Variables: Session
}>()
  .use("/*", authMiddleware)
  .get("/session", (c) => {
    const data = c.get("session")
    return c.json({ data })
  })
  .get("/user", (c) => {
    const data = c.get("user")
    return c.json({ data })
  })

Authentication Middleware

The auth middleware validates sessions and adds user context:
api/hono/src/middlewares/auth.ts
import type { Session } from "@packages/auth"
import { auth } from "@packages/auth"
import { createMiddleware } from "hono/factory"

export const authMiddleware = createMiddleware<{ Variables: Session }>(
  async (c, next) => {
    const session = await auth.api.getSession({ headers: c.req.raw.headers })

    if (!session) {
      return c.json(
        { error: { code: "UNAUTHORIZED", message: "Unauthorized" } },
        401
      )
    }

    c.set("session", session.session)
    c.set("user", session.user)

    return next()
  }
)

Client Setup

Creating the Client

The frontend imports the API type and creates a typed client:
web/next/src/lib/api/client.ts
import type { AppType } from "@api/hono"
import { hc } from "hono/client"
import { config } from "@/lib/config"

type Client = ReturnType<typeof hc<AppType>>

const hcWithType = (...args: Parameters<typeof hc>): Client => 
  hc<AppType>(...args)

const url = config.api.internalUrl ? config.api.internalUrl : config.api.url

const honoClient = hcWithType(url, {
  init: {
    credentials: "include",
  },
})

export const apiClient = honoClient.api
The credentials: "include" option ensures cookies (including auth sessions) are sent with requests.

Internal vs External URLs

The client supports both internal (server-side) and external (client-side) API URLs:
web/next/src/lib/config.ts
const getInternalApiUrl = () => {
  if (typeof window === "undefined") {
    return env.INTERNAL_API_URL
  }
  return undefined
}

export const config = {
  api: {
    url: env.NEXT_PUBLIC_API_URL,
    internalUrl: getInternalApiUrl(),
  },
}
This allows server components to use internal Docker networking while client components use public URLs.

Client Usage

Making Requests

The client provides full autocomplete and type checking:
"use client"

import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"

export function ApiStatus() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ["api-health"],
    queryFn: async () => {
      const res = await apiClient.health.$get()
      if (!res.ok) {
        throw new Error("Systems are facing issues")
      }
      return res.json()
    },
    refetchInterval: 30000,
  })

  // data is fully typed: { data: { message: string, version: string, environment: string } }
}

Protected Routes

Client-side requests to protected routes require authentication:
web/next/src/components/user-profile.tsx
"use client"

import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"

export function UserProfile() {
  const { data } = useQuery({
    queryKey: ["user"],
    queryFn: async () => {
      const response = await apiClient.v1.user.$get()
      if (!response.ok) throw new Error("Failed to fetch user")
      return response.json()
    },
  })

  return <div>{data?.data.name}</div>
}

Server-Side Session Check

Server components can validate sessions using the auth helper:
web/next/src/lib/auth/index.ts
import type { Session } from "@packages/auth"
import { headers } from "next/headers"
import { apiClient } from "@/lib/api/client"

export const auth = {
  api: {
    getSession: async () => {
      try {
        const response = await apiClient.auth["get-session"].$get(
          undefined,
          {
            headers: Object.fromEntries((await headers()).entries()),
          }
        )
        if (!response.ok) return null
        const text = await response.text()
        if (!text) return null
        return JSON.parse(text) as Session | null
      } catch {
        return null
      }
    },
  },
}
Usage in layout or page:
app/(protected)/layout.tsx
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"

export default async function Layout({ children }) {
  const session = await auth.api.getSession()

  if (!session?.user) redirect("/")

  return <>{children}</>
}

Type Safety Benefits

Request Type Checking

The client validates request parameters at compile time:
// TypeScript error: Property 'invalidParam' does not exist
await apiClient.v1.session.$get({ invalidParam: true })

// TypeScript error: Expected 0 arguments, but got 1
await apiClient.health.$get({ query: { foo: "bar" } })

Response Type Inference

Response types are automatically inferred:
const response = await apiClient.health.$get()
const { data } = await response.json()

// data.message is string
// data.version is string  
// data.environment is "local" | "development" | "test" | "staging" | "production"

Auto-complete for Paths

IDEs provide full autocomplete for all available routes:
apiClient.
  // Autocomplete shows:
  // - health
  // - auth
  // - v1

OpenAPI Integration

While Hono RPC provides TypeScript types, you can also generate OpenAPI specs for documentation:
api/hono/src/index.ts
import { describeRoute, resolver, openAPIRouteHandler } from "hono-openapi"
import { z } from "zod"

const routes = app.get(
  "/health",
  describeRoute({
    tags: ["System"],
    description: "Get the system health",
    responses: {
      200: {
        description: "OK",
        content: {
          "application/json": {
            schema: resolver(
              z.object({
                data: z.object({
                  environment: z.enum(["local", "development", "test", "staging", "production"]),
                  message: z.string(),
                  version: z.string(),
                }),
              })
            ),
          },
        },
      },
    },
  }),
  (c) => {
    const data = { message: "ok", version: BUILD_VERSION, environment: env.NODE_ENV }
    return c.json({ data })
  }
)
The OpenAPI spec is served at /api/openapi.json and documented at /api/docs.

Error Handling

Client-Side Error Handling

try {
  const response = await apiClient.v1.user.$get()
  
  if (!response.ok) {
    const error = await response.json()
    // error.error.code is "UNAUTHORIZED" | "NOT_FOUND" | etc.
    // error.error.message is string
    throw new Error(error.error.message)
  }
  
  const data = await response.json()
} catch (error) {
  console.error("Request failed:", error)
}

Server Error Handler

api/hono/src/lib/error.ts
import type { ErrorHandler } from "hono"

export const errorHandler: ErrorHandler = (err, c) => {
  console.error(err)
  
  return c.json(
    {
      error: {
        code: "INTERNAL_SERVER_ERROR",
        message: "An unexpected error occurred",
      },
    },
    500
  )
}

Best Practices

React Query provides caching, refetching, and state management:
const { data, isLoading, error } = useQuery({
  queryKey: ["user", userId],
  queryFn: async () => {
    const res = await apiClient.v1.user.$get()
    return res.json()
  },
})
Always verify the response succeeded:
const response = await apiClient.health.$get()
if (!response.ok) {
  throw new Error("Request failed")
}
const data = await response.json()
For server components, pass headers to maintain session:
const response = await apiClient.auth["get-session"].$get(
  undefined,
  { headers: Object.fromEntries((await headers()).entries()) }
)

Comparison to Other Approaches

FeatureHono RPCtRPCREST + Codegen
Type Safety✅ Full✅ Full⚠️ Codegen required
Build Step✅ None✅ None❌ Required
Standard HTTP✅ Yes❌ No✅ Yes
OpenAPI Support✅ Yes❌ No✅ Yes
Bundle Size✅ Small⚠️ Medium✅ Small
Hono RPC provides the best of all worlds: full type safety, no build steps, and standard HTTP/REST APIs.