iTranslated by AI
Exploring the New Type-Safe Experience with Hono and Inertia.js
Introduction
// Server-side (Hono)
app.get('/posts/:id', (c) => {
const post = findPost(c.req.param('id'))
return c.render('Posts/Show', { post })
})
// Client-side (React)
export default function Show({ post }: PageProps<'Posts/Show'>) {
return <h1>{post.title}</h1>
}
The type of post is perfectly inferred as Post. There is no API definition, DTO, tRPC, or schema generation in between. The object passed as the second argument to c.render() on the server arrives directly in React as props, fully typed.
The Inertia.js protocol, which promotes "SPA without an API," has been used for years with Laravel and Rails. However, this article covers what lies beyond that—when you combine TypeScript and Hono, Inertia becomes a type-penetrating experience that even its original implementation cannot achieve. And it does so with just a 60-line adapter.
It has captured the attention of wada, the author of Hono, whom I also greatly admire. This is the unique potential inherent in a TypeScript-native implementation of Inertia.js.
In this article, we will examine the sample Hono × Inertia.js application published by wada to uncover why such a transparent full-stack experience is possible.
What is Inertia.js?
Inertia.js is a mechanism for "building SPAs without building an API."
Normally, when building an SPA with React/Vue, you set up a REST/GraphQL API on the server and fetch data from the client. Inertia eliminates this API layer. The server returns the React component name and props directly as JSON, and the Inertia runtime on the client renders it.
// Shape of the JSON returned by the server
{
component: 'Posts/Show', // Page to display
props: { post: {...} }, // Data passed to React
url: '/posts/1', // URL for history management
version: 'abc123' // For asset integrity verification
}
Inertia calls this a "page object." The server's responsibility is limited to "returning a page object," rendering client-side routing, state management, and fetch logic unnecessary.
When you click a <Link>, a request is sent with the X-Inertia: true header, a page object is returned, and React replaces the old component with the new one. You get the full SPA experience without the need for an API layer.
The official documentation calls it a "Modern Monolith." It is highly compatible with applications where SEO is unnecessary, such as internal business apps where state is self-contained within each page.
This protocol is language-agnostic. In the yusukebe/hono-inertia-example we are referencing, it is integrated into Hono using approximately 60 lines of middleware. In the next section, we will focus on the unexpected benefit brought by its lightweight nature: the type-penetrating SPA experience.
The Magic of Type Penetration: src/page-props.ts
How does the client-side React component receive the post: Post type information from the server-side code written as c.render('Posts/Show', { post })?
What is happening here is a four-stage propagation using the TypeScript type system. Let's trace it step by step.
Overview: The 4-Stage Type Chain
[1] Type declaration where c.render returns TypedResponse
↓
[2] Hono collects the output of all routes via ExtractSchema
↓
[3] Registration in AppRegistry as "this is our app"
↓
[4] PageProps<C> extracts the relevant props from the union
Phase 1: Embedding the return type of c.render
Inside src/renderer.tsx, there is the following type declaration:
declare module 'hono' {
interface ContextRenderer {
<C extends PageName, P = Record<string, never>>(
component: C,
props?: P
): Response & TypedResponse<{ component: C; props: P }, 200, 'html'>
}
}
When you call c.render('Posts/Show', { post }), TypeScript saves 'Posts/Show' into the generic C and { post: Post } into P. The return value is recorded as a TypedResponse<{ component: 'Posts/Show'; props: { post: Post } }> type.
Nothing has happened at runtime at this point. It is purely a type-level "receipt issuance." However, Hono can aggregate these receipts later.
Phase 2: Gathering all routes with ExtractSchema
Hono has a built-in type utility called ExtractSchema<App>. This works on the same mechanism as tRPC's AppRouter type, extracting the type signatures of all routes from the router.
type Schema = ExtractSchema<typeof app>
// → Conceptually looks like this shape:
// {
// '/posts/:id': {
// GET: { output: { component: 'Posts/Show'; props: { post: Post } } }
// },
// '/posts': {
// GET: { output: { component: 'Posts/Index'; props: { posts: Post[] } } }
// },
// ...
// }
From the c.render(...) calls on the server side, it is possible to exhaustively extract "which component is returned with what props" as a schema for each route.
Phase 3: Registration with AppRegistry (module augmentation)
However, client-side helper types cannot directly import the types of a specific Hono app. If the server references the client and the client references the server, it creates a circular dependency.
Therefore, this sample code adopts the AppRegistry pattern.
// src/page-props.ts
export interface AppRegistry {} // Empty interface
type RegisteredApp = AppRegistry extends { app: infer A } ? A : never
// app/pages.gen.ts (Auto-generated by Vite plugin)
declare module '../src/page-props' {
interface AppRegistry {
app: typeof app // ← Declare "this is our app" here
}
}
Using TypeScript's declare module syntax allows you to add properties to an interface after the fact (module augmentation). The pages.gen.ts file, automatically generated by the Vite plugin upon detecting app/server.ts, handles this binding.
The structure works by "using auto-generated files to loosely connect the type systems of the server and the client."
Phase 4: Extracting the props type with PageProps<C>
With this, we have reached a state where "the return types of all routes can be retrieved as a schema." The rest is just extracting the props for a specific page.
type RenderOutput<App> = /* Type that gathered all ExtractSchema outputs as a union */
export type PageProps<C> = Extract<
RenderOutput<RegisteredApp>,
{ component: C }
>['props']
TypeScript's Extract is a utility that pulls out only the matching conditions from a union. When calculating PageProps<'Posts/Show'>, the following occurs:
1. Extract elements that match { component: 'Posts/Show', ... } from the entire union
→ { component: 'Posts/Show'; props: { post: Post } }
2. Retrieve its ['props']
→ { post: Post }
With this, PageProps<'Posts/Show'> is inferred as { post: Post }. From a single line of c.render written on the server side, the types penetrate all the way through.
The Decisive Difference from the Laravel / Rails Versions
The statement quoted earlier about "the DX potentially becoming better than using it with Laravel or Rails" refers precisely to this mechanism.
The Reality of Laravel + Inertia
- Schemas are defined twice on the server side (PHP) and the client side (TypeScript)
- Client props types are written manually (type inference cannot cross language boundaries)
- Typos in page names are only noticed at runtime
Hono + Inertia
- Schemas exist in only one place on the server side
- Client props types are fully automatically inferred
- Typos in page names result in compilation errors via the
PageNameunion type
This is an advantage that only exists when building the entire stack in TypeScript, an experience long-time Laravel and Rails Inertia users will surely envy.
Difference in Granularity from tRPC
You might feel that "tRPC would suffice if the only goal is type penetration." While they are similar technologies, their granularity differs.
| tRPC | Hono + Inertia | |
|---|---|---|
| Type flow target | Individual API procedures | Entire page props |
| Client invocation | trpc.posts.get.useQuery() |
<Link href="/posts/1"> |
| Routing | Router on the client side too | Server side only |
| Data fetch timing | Per component | In one batch during page transition |
tRPC involves "calling functions as APIs with types," while Inertia involves "receiving the entire page with types." If the former is component-level granularity, the latter is page-level granularity.
In apps where "pages are the unit of state," such as internal business applications, Inertia's granularity is often more intuitive to write.
Experience It
Try opening app/pages/Posts/Show.tsx and press the dot after post.. You will see id, title, and body in the autocomplete. This is evidence that the data type written as posts: Post[] on the server side has arrived here without any manual declaration.
If you try to pass it incorrectly on the server side, such as c.render('Posts/Show', { post: post.title }), it will immediately result in a compilation error. The server and client are truly connected by types.
This is the new form of Inertia born from the combination of TypeScript and Hono.
Summary: What's New?
The combination of Hono × Inertia.js × TypeScript realizes a type-penetrating SPA experience that even the Laravel/Rails versions of Inertia could not reach. Let's summarize the differences.
| Laravel + Inertia | Hono + Inertia | |
|---|---|---|
| Server language | PHP | TypeScript |
| Schema definition location | Double (PHP + TS) | Server side only |
| Client props types | Manual | Automatic inference |
| Page name typo detection | Runtime | Compilation time |
This is an advantage that only holds true when building the entire stack in TypeScript. It emerged naturally when Hono's philosophy of transparency (the code you write runs, minimizing abstraction) met Inertia's lightweight protocol (a 4-property page object). The adapter itself is just 60 lines of code.
Update: Official middleware integration in progress
By the way, while writing this article, a PR was published by wada himself to promote this to Hono's official middleware as @hono/inertia (honojs/middleware#1867). Once merged, the logic deciphered in this article will be available directly via npm i @hono/inertia.
In the official version, the type penetration logic in page-props.ts is expected to be almost identical to the content covered in this article, with additions such as rootView options and asset version consistency checks upon 409 Conflict. The explanations in this article remain valid; just translate the API names in your mind (e.g., renderer() → inertia(), changing the import source) when the official version arrives.
Hono × Inertia.js has only just begun.
Discussion