RPC

Learn how to make type-safe API calls from your client to your server with Hono RPC.

Creating a new API endpoint

You can make a new API endpoint within the /apps/web/src/server/router directory.

/apps/web/src/server/router/example/router.ts
import { getFactory } from "@/server/router/factory";

export const exampleRouter = getFactory()
    .createApp()
    .get("/", async (c) => {
        return c.json({ ok: true }, 200);
    });

If you've added a new router, you will need to ensure that the route contains a path to the router you defined in /apps/web/src/app/api/[[...route]]/route.ts.

/apps/web/src/app/api/[[...route]]/route.ts
const app = new Hono<{ Bindings: CloudflareEnv }>()
    .basePath("/api")
    .use(cors)
    .route("/example", router.example);

Protecting an endpoint

You can protect an endpoint by adding the isAuthenticated middleware to the router.

/apps/web/src/server/router/example/router.ts
import { getFactory } from "@/server/router/factory";
import { isAuthenticated } from "@/server/router/middleware";

export const exampleRouter = getFactory()
    .createApp()
    .get("/", isAuthenticated({ id: "example" }), async (c) => {
        // If `isAuthenticated` is provided, the user will be available
        // in the context variable `c.var`
        const { user } = c.var;

        return c.json({ ok: true, user }, 200);
    });

Making an API call

hono.ts
import { rpc } from "@/lib/rpc";

// This makes a GET request to the /api/teams endpoint
const res = await rpc.api.teams.$get();

if (res.status !== 200 || !res.ok) {
    throw new Error("Failed to fetch teams");
}

const teams = await res.json().then((data) => data.teams);

Querying in React

page.tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { rpc } from "@/lib/rpc";
import type { FC } from "react";

const Page: FC = () => {
    const teams = useQuery({
        // This is a utility to create a query key, which is used to cache
        // the result
        queryKey: rpc.$key(rpc.api.teams),
        queryFn: async () => {
            // This makes a GET request to the /api/teams endpoint
            const res = await rpc.api.teams.$get();

            if (res.status !== 200 || !res.ok) {
                throw new Error("Failed to fetch teams");
            }

            return res.json().then((data) => data.teams);
        },
    });

    return (
        <div>
            {teams.isPending && <p>Loading...</p>}
            {teams.isError && <p>Error: {teams.error.message}</p>}
            {teams.isSuccess && (
                <ul>
                    {teams.data.map((team) => (
                        <li key={team.id}>{team.name}</li>
                    ))}
                </ul>
            )}
        </div>
    );
};

export default Page;

Mutating in React

page.tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { rpc } from "@/lib/rpc";
import type { InferRequestType } from "hono";
import type { FC } from "react";

interface PageProps {
    params: Promise<{ teamSlug: string }>;
}

const Page: FC<PageProps> = async (props) => {
    const { teamSlug } = await props.params;

    const queryClient = useQueryClient();

    // This makes a PATCH request to the /api/teams/:urlSlug endpoint
    const updateFn = rpc.api.teams[":urlSlug"].$patch;
    const updateTeam = useMutation({
        mutationFn: async (input: InferRequestType<typeof updateFn>) => {
            const res = await updateFn(input);

            if (res.status !== 200 || !res.ok) {
                throw new Error("Failed to update team");
            }

            const updated = res.json().then((data) => data.team);

            console.log("Updated team:", updated);
        },
        // This is used to invalidate the query cache for the teams endpoint
        onSuccess: async () => {
            await queryClient.invalidateQueries({
                queryKey: rpc.$key(rpc.api.teams),
            });
        },
    });

    const handleUpdate = async () => {
        await updateTeam.mutateAsync({
            // This specifies the :urlSlug parameter in the endpoint
            // The type is inferred from the endpoint definition
            // so you get type safety here
            param: { urlSlug: teamSlug },
            // This is the request body
            // The type is also inferred from the endpoint definition
            // so you get type safety here as well
            json: { name: "My new team" },
        });
    };

    return <div>{/* ... */}</div>;
};

TypeScript performance

TypeScript performance is a common concern when using heavily type-inferred libraries. Hono RPC is no exception. As you define more endpoints, you may notice a slowdown in your IDE's performance, particularly with intellisense and type checking.

When working within the @workspace-apps/web app, all of your types should be automatically inferred between the backend and frontend; however, you can run pnpm hono:generate to statically generate the types for your endpoints. Once you run this command, the generated RPC with flat types will be made available in /apps/web/hono/rpc.

You can continue using the rpc with fully inferred types, but if you find that your IDE is starting to struggle with performance, you can switch to rpc with flat types by importing from the generated rpc instead.

/apps/web
pnpm hono:generate
page.tsx
// rpc with generated flat types
// this is extremely performant, but requires you to run `pnpm hono:generate`
import { rpc } from "@/hono/rpc";

// rpc with fully inferred types
// this may show down your Next.js dev server and IDE performance,
// but it does not require you to run `pnpm hono:generate`
import { rpc } from "@/lib/rpc";

Why not tRPC?

tRPC is a fantastic library for building type-safe APIs in TypeScript, and there are no reasons why you shouldn't use it if you enjoy its API. However, there are a few reasons why we chose to use Hono RPC instead.

Built on Web Standards

Hono RPC is built on top of Web Standards, notably the Fetch API, and includes basic HTTP primitives like Request, Response, URL and Headers.

Building on top of these standards makes it easier to follow tutorials and documentation that are based on these standards, and makes it more interoperable with other libraries and tools in the JavaScript and web ecosystems.

Type Safety

Like tRPC, Hono RPC allows you to share types between your client and server, ensuring that your API calls are type-safe end-to-end.

example.ts
import {  as  } from "@hono/zod-validator";
import {  } from "hono";
import {  } from "hono/client";
import {  } from "zod";

// server
const  = new ().("/api").(
        "/teams",
        ("json", .({ : .() })),
        () => {
            const {  } = ..("json");
            const  = { : "123",  };

            return .({ :  }, 200);
        },
    );

// client
const  = <typeof >("http://localhost:3000");
const  = await ...({
    // This makes a POST request to the /api/teams endpoint
    // json is the request body and is type-safe
    : { : "My new team" },
})
    // res is a type-safe standard Fetch Response
    .(() => . === 200 ? .() : null)
    .(() => ?. ?? null);

Not only that, but each HTTP status code is also type-safe, so you can return different data for each status code, and the client will differentiate between them via TypeScript discriminated unions.

Type Performance

tRPC has been designed to always be type-inferred, which can lead to performance issues in larger applications as more procedures are added. For instance, one such user cites that resolving types takes 4-8 seconds on a M1 Max and 32 GB of RAM, with another reply citing that casting the entire router to any resolves performance issues.

Meanwhile, when you create a new Hono RPC and provide it your Hono app type, the Hono client will strip all backend-specific types (e.g. your server's context) and only retains the types that are relevant to the client. This means that you can start with fully inferred types, which is great for development; then you can statically generate flat types later on as your application scales to improve TypeScript performance in your IDE.

Trade-offs

  • Because the Hono client strips all backend-specific types, you do lose the ability to cmd+click into the backend code from the client. While that is inconvenient, your routes should be easy to find in code with good routing organization (the ship.pluv.io templates do this well).
  • tRPC does have solutions like xtrpc, which users have created to solve tRPC performance issues. However, these solutions are not an official part of tRPC and can be unreliable as a result.

tRPC vs Hono RPC

tRPCHono
npmnpmnpm
GitHubhttps://github.com/trpc/trpchttps://github.com/honojs/hono
Built on Web Standards🪄 No. Abstracts away standard APIs🛠️ Yes. Builds around standard APIs
End-to-end type-safety🛡️ Yes🛡️ Yes
IDE-performant at scale🐌 No⚡ Yes, when using flat types. Else no
Can build flat types🛑 No. At least would be very difficult✅ Yes. Can simply use tsc
Can cmd+click to server🖱️ Yes. Goes straight to the procedure🔎 No. Must search for the endpoint in code
Works with React Query✅ Yes✅ Yes
Client/Server coupling🔗 Tightly coupled🌱 Uncoupled. Can be in different repos and different languages
Routing modelProcedure-basedHTTP verb and path-based