Hono + tRPC
One of the core philosophies of VHS is end-to-end type safety, and the combination of Hono and tRPC is how we achieve this for the API layer. Hono provides the high-performance, lightweight server, while tRPC builds a type-safe bridge between that server and your client-side code.
This guide will walk you through the entire flow, from how the server is set up to how you can call your API procedures on the frontend with full type safety and autocompletion.
1. How Hono and tRPC Connect
Section titled “1. How Hono and tRPC Connect”The integration begins at the server entrypoint. Hono acts as the main web server, handling incoming HTTP requests. We then tell Hono to forward any requests that come in on a specific path to our tRPC handler.
This setup happens in src/hono-entry.ts
:
import { createHandler } from "@universal-middleware/hono";import { Hono } from "hono";import { cors } from "hono/cors";import { trpcHandler } from "./server/trpc-handler";
const app = new Hono();
app.use("*", cors());
// This is the magic line:app.use("/api/trpc/*", createHandler(trpcHandler)("/api/trpc"));
2. Setting Up Your tRPC Instance
Section titled “2. Setting Up Your tRPC Instance”The core tRPC configuration lives in src/server/api/trpc.ts
. This file is where we create the building blocks for our entire API.
import { initTRPC } from "@trpc/server";import superjson from "superjson";
// 1. Create the tRPC Contextexport const createTRPCContext = async (opts: { req?: Request }) => { // Here you can add things that all your procedures can access. // For example: database connection, user session, etc. return { // db: getDb(), // user: await getUserFromHeader(opts.req?.headers), };};
// 2. Initialize tRPCconst t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, // Enables richer data serialization});
// 3. Export helper functionsexport const createTRPCRouter = t.router;export const publicProcedure = t.procedure;
There are three key concepts here:
- Context (
createTRPCContext
): This is an object that is available to all of your tRPC procedures. It’s the perfect place to put shared resources like a database connection or the current user’s authentication session. This avoids having to pass these resources to every single function. - Initialization (
initTRPC
): This is where we create our tRPC instance, telling it what our context looks like and enabling helpful tools like superjson, which allows you to send data types like Date, Map, and Set over the wire seamlessly. - Helpers (
createTRPCRouter
,publicProcedure
): These are the reusable tools we’ll use to define our API endpoints in the next step. ApublicProcedure
is an endpoint that anyone can access. You could also create aprotectedProcedure
here to handle authenticated requests.
3. Defining Your API Endpoints
Section titled “3. Defining Your API Endpoints”With the setup complete, you can now create your API endpoints. We organize these into “routers,” which are collections of related procedures.
Here is an example of a simple router for posts in src/server/api/routers/post.ts
:
import { z } from "zod";import { createTRPCRouter, publicProcedure } from "../trpc";
export const postRouter = createTRPCRouter({ // 'hello' is the name of our procedure hello: publicProcedure // '.input()' defines the expected input and validates it with Zod .input(z.object({ text: z.string() })) // '.query()' defines it as a data-fetching endpoint .query(({ input, ctx }) => { // This is the actual server-side logic // 'input' is fully typed and validated // 'ctx' is the context we defined earlier return { greeting: `Hello ${input.text}`, }; }),});
This file defines a single endpoint at post.hello
. Notice how Zod is used to define the input. tRPC will automatically validate incoming requests against this schema. If a request doesn’t match, it will be rejected before your code even runs.
4. Type-Safe Data Fetching on the Client
Section titled “4. Type-Safe Data Fetching on the Client”This is where the magic happens. On the client, you can now call this server procedure as if it were a local function, with full type safety and autocompletion.
// In a SolidJS componentimport { api } from "@/lib/api"; // Your pre-configured tRPC clientimport { useQuery } from "@tanstack/solid-query";
const MyComponent = () => { const helloQuery = useQuery(() => ({ queryKey: ["hello"], queryFn: () => api.post.hello.query({ text: "from tRPC" }), }));
return ( <div> {helloQuery.data?.greeting} </div> );};
Look closely at the queryFn: api.post.hello.query({ text: "from tRPC" })
.
- No URL strings: You’re not writing
fetch(/api/post/hello')
. - Full Autocompletion: Your editor knows that
api.post
exists, that it has ahello
procedure, and that it’s aquery
. - Input Typing: TypeScript knows that the
query
method requires an object with atext
property of typestring
. If you try to pass a number or misspell the key, you’ll get an error instantly in your editor. - Output Typing: The return value,
helloQuery.data
, is also fully typed. TypeScript knows it will be an object with agreeting
property of typestring
.