iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔥

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.

https://x.com/yusukebe/status/2048672263722152238

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.

https://github.com/yusukebe/hono-inertia-example

https://zenn.dev/ashunar0/articles/cc351badf8681c


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 PageName union 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.

https://zenn.dev/ashunar0/articles/cc351badf8681c

Discussion