Many people have heard of Vite’s ability to hot-reload modules in the browser, and have used it that way. But Vite can do more, especially for modern workarounds, new backend frameworks tend to support Vite’s development server as well. One of them is Hono.
Hono is a relatively new framework compared to traditional frameworks such as Express. Its serverless design with modern web APIs really caught my eye. With Vite, Hono applications benefit from hot module reloading as well, which provides us with a whole new way of developing full-stack applications.
I had the opportunity to convince my colleagues (in my new workplace) to switch from an old architecture that uses the Koa/Egg.js framework and Webpack to this new Vite + Hono composition and benefit from it a lot. With this new full-stack architecture, we not only managed to reduce the unnecessary division between the backend and the frontend but also improved the quality of code since Hono can share API types between the backend and the frontend.
So here I’m going to share my experience of using this new stack with people who want to embrace the new era of web development. Let’s dive in.
1. Initiate a React project according to Vite’s official guidance
Just use the following command, as demonstrated in Vite’s official doc, no magic is needed.
npm create vite@latest vh-stack -- --template react-ts
cd vh-stack
npm install && npm run dev
BashWith any luck, we shall see some logs printed in our terminal, display that we have a Vite dev server running, like this
VITE v5.0.10 ready in 258 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
HTML2. Integrate Hono into Vite
Now we have a React project running by Vite’s dev server. All we have to do is gracefully add Hono and related components to integrate with Vite.
First, we’ll need to install Hono and its Vite plugin, as follows:
npm i cross-env hono @hono/node-server
npm i -D @types/node @hono/vite-dev-server
BashThen modify vite.config.ts
to include the Hono plugin:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import devServer from '@hono/vite-dev-server'
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 4000, // change to a custom port
},
build: {
outDir: "build", // change to 'build', explain later
},
plugins: [
react(),
devServer({
entry: "server.ts",
exclude: [ // We need to override this option since the default setting doesn't fit
/.*\.tsx?($|\?)/,
/.*\.(s?css|less)($|\?)/,
/.*\.(svg|png)($|\?)/,
/^\/@.+$/,
/^\/favicon\.ico$/,
/^\/(public|assets|static)\/.+/,
/^\/node_modules\/.*/
],
injectClientScript: false, // This option is buggy, disable it and inject the code manually
})
],
})
TypeScript3. Reconstruct our project
Now it’s time to write some Hono applications. Create a file in the project folder named server.ts
as set in the above config.
// server.ts
import { Hono } from "hono"
import { serve } from "@hono/node-server"
import { serveStatic } from "@hono/node-server/serve-static"
import { readFile } from "node:fs/promises"
const isProd = process.env["NODE_ENV"] === "production"
let html = await readFile(isProd ? "build/index.html" : "index.html", "utf8")
if (!isProd) {
// Inject Vite client code to the HTML
html = html.replace("<head>", `
<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
`)
}
const app = new Hono()
.use("/assets/*", serveStatic({ root: isProd ? "build/" : "./" })) // path must end with '/'
.get("/*", c => c.html(html))
export default app
if (isProd) {
serve({ ...app, port: 4000 }, info => {
console.log(`Listening on http://localhost:${info.port}`);
});
}
TypeScriptNow we should see that the Vite program is hot-reloaded, and our web page is served by Hono instead of the built-in Vite dev server.
If you pay attention, you will notice that I use views/assets
instead of src/assets
in the serveStatic
middleware, this is intentional, as we are developing a full-stack application, the default src
folder only contains TSX files for the frontend, so it’s not an ideal directory structure to store our files.
So the next step would be to create an ideal structure for our application. From my several years of building full-stack applications, I find the following structure is rather elegant.
api
The folder that stores our API gateway applications, A.K.A. route handlers or controllers written in TS.build
The folder that stores frontend build files, our bundled HTML, JS, CSS files will be stored here.components
The folder that stores reusable frontend React components.dist
The folder that stores backend compiled JS files.models
The folder that stores model or schema files.public
The folder that stores static and public resources.services
This folder is not mandatory, but many people like to abstract business logic into services.utils
The folder that stores utility functions.common.ts
Utility functions for both backend and frontend.backend.ts
Utility functions for backend specifically.frontend.tsx
Utility functions for frontend specifically.
views
The folder that stores UI views, A.K.A. web pages written in TSX.server.ts
The entry file of backend.client.tsx
The entry file of frontend.index.html
The web page that hosts the web app.
So we need to first rename the src
folder to views
, and then rename the views/main.tsx
to client.tsx
. after renaming, its contents should look like this:
// client.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './views/App.tsx'
import './views/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
TSX I won’t talk about client.tsx
in detail, we just need to update the script.src
property in the index.html
to client.tsx
, like this:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Hono + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/client.tsx"></script>
</body>
</html>
HTML4. Reconfigure for full-stack development
Although our program now runs fine, and if we try to modify the code in server.ts
, Vite will hot-reload the server module as expected. For example, we can add a middleware to the Hono app to check the effect.
// server.ts
// ...
app.use("*", async (c, next) => {
c.res.headers.set("X-Powered-By", "Hono")
await next()
})
/...
TypeScriptwe will see not only our web page got hot-reloaded, but the new responses now have a new X-Powered-By: Hono
header.
But I do not want to stop here, I want to present an actual application structure and code examples of this architecture. So let’s keep walking.
The next part is to configure the build tool, although we now have a build
script for Vite to build the frontend application, we still lack the configuration to build the backend application.
I would recommend using Rollup for building the backend Node.js application. Why not just use tsc
? You may ask. Well I can tell you why: the tsc
is very bad at supporting TypeScript. It doesn’t allow us to use .ts
extensions in the source files, or we need to change a lot of our tsconfig.json
file and then break the frontend config. As our project relies on one tsconfig.json
that works for both the frontend and the backend, I don’t want to risk anything just to work with tsc
, so Rollup it is.
First, delete the tsconfig.node.json
that was created by Vite, which is useless for us, and modify the tsconfig.json
as follows:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules/**"]
}
JSONCThen install Rollup and the related plugins,
npm i -D glob rollup @rollup/plugin-typescript @rollup/plugin-node-resolve @rollup/plugin-commonjs
BashAnd create a rollup.config.mjs
file with the following contents:
import { glob } from "glob"
import { extname, sep } from "node:path"
import { fileURLToPath } from "node:url"
import { builtinModules } from "node:module"
import typescript from "@rollup/plugin-typescript"
import resolve from "@rollup/plugin-node-resolve"
import commonjs from "@rollup/plugin-commonjs"
export default {
input: Object.fromEntries(glob.sync([
"server.ts",
"api/**/*.ts",
"models/**/*.ts",
"services/**/*.ts",
"utils/**/*.ts",
], {
ignore: [
"**/*.d.ts",
"**/*.test.ts",
]
}).map(file => [
file.slice(0, file.length - extname(file).length),
fileURLToPath(new URL(file, import.meta.url))
])),
output: {
dir: "dist", // set to 'dist' as mentioned earlier
format: "esm",
sourcemap: true,
preserveModules: true,
preserveModulesRoot: ".",
},
external(id) {
return id.includes(sep + 'node_modules' + sep)
},
plugins: [
typescript({ moduleResolution: "bundler" }),
resolve({ preferBuiltins: true }),
commonjs({ ignoreDynamicRequires: true, ignore: builtinModules }),
]
}
JavaScriptThen modify package.json
, change the build
script to tsc && vite build && rollup -c rollup.config.mjs
. Now every time we run npm run build
, both the frontend and the backend will be compiled.
Finally, in package.json
, add a new script start
, which should be cross-env NODE_ENV=production node dist/server.js
. That’s it, after running npm run build
, we can use npm run start
to start our application in production mode.
5. An actual example
It would be incomplete if this article didn’t include actual backend logic. So I just created a simple todo example, combining the usage of model, service, API gateway (or controller if you’d like to call it that way) and utility functions.
// models/Todo.ts
import { z } from "zod";
const Todo = z.object({
id: z.number().int(),
title: z.string(),
description: z.string(),
deadline: z.date(),
})
type Todo = z.infer<typeof Todo>
export default Todo
TypeScript// services/TodoService.ts
import Todo from "../models/Todo";
export default class TodoService {
private idCounter = 0;
private store: (Todo | null)[] = [];
async list() {
const list = this.store.filter(item => item !== null) as Todo[]
return await Promise.resolve(list); // simulate async, service method should always be async
}
async add(item: Omit<Todo, "id">) {
const id = ++this.idCounter;
const todo = { id, ...item }
this.store.push(todo);
return await Promise.resolve(todo);
}
async delete(query: Pick<Todo, "id">) {
const index = this.store.findIndex(item => item?.id === query.id)
if (index === -1) {
return false
} else {
this.store[index] = null
return true
}
}
async update(query: Pick<Todo, "id">, data: Omit<Todo, "id">) {
const todo = this.store.find(item => item?.id === query.id)
if (todo) {
return Object.assign(todo, data)
} else {
throw new Error("todo not found")
}
}
}
TypeScript// api/todo.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { useService } from "../utils/backend";
import TodoService from "../services/TodoService";
import Todo from "../models/Todo";
const todoService = useService(TodoService)
// Must use chaining syntax, otherwise `hc` will lose types.
const todo = new Hono()
.get("/list", async c => {
const list = await todoService.list()
return c.json({
success: true,
data: list,
})
})
.post("/", zValidator("json", Todo.omit({ id: true })), async c => {
const data = c.req.valid("json")
const todo = await todoService.add(data)
return c.json({
success: true,
data: todo,
})
})
.patch("/:id", zValidator("json", Todo.omit({ id: true })), async c => {
const id = Number(c.req.param("id"))
const data = c.req.valid("json")
const todo = await todoService.update({ id }, data)
return c.json({
success: true,
data: todo,
})
})
.delete("/:id", async c => {
const id = Number(c.req.param("id"))
const ok = await todoService.delete({id})
return c.json({
success: ok,
data: null,
})
})
export default todo
TypeScript// api/index.ts
import { Hono } from "hono"
import todo from "./todo"
// Always register routes in an index.ts file.
// Must use chaining syntax, otherwise `hc` will lose types.
const api = new Hono()
.route("/todo", todo)
export default api
TypeScript// utils/backend.ts
const singleton = Symbol.for("singleton")
export function useService<T>(ctor: (new () => T) & {
[singleton]?: T;
}): T {
if (!ctor[singleton]) {
ctor[singleton] = new ctor()
}
return ctor[singleton]
}
TypeScript// utils/frontend.ts
import { hc } from "hono/client";
import type { AppType } from "../server";
const { api } = hc<AppType>("/", {
headers: {
"Content-Type": "application/json"
}
});
export { api }
TypeScript// server.ts
// ...
// Must use chaining syntax, otherwise `hc` will lose types.
const app = new Hono()
.use("*", async (c, next) => {
c.res.headers.set("X-Powered-By", "Hono");
await next();
})
.route("/api", api) // register the API endpoint
.use("/assets/*", serveStatic({ root: isProd ? "build/" : "./" })) // path must end with '/'
.get("/*", c => c.html(html));
export default app
export type AppType = typeof app
// ...
TypeScriptAs these examples really take too long, I’m going to simplify the React part of code that uses the api
namespace to access backend data, readers can just check Hono’s official doc for more details, the corresponding link is:
https://hono.dev/guides/rpc#client
Basically, frontend components and pages just import the api
variable from utils/frontend.ts
, and use it as if calling local functions, for example:
// views/index.tsx
import { useEffect, useState } from "react"
import { api } from "../utils/frontend"
import Todo from "../models/Todo"
export function IndexPage() {
const [todoList, setTodoList] = useState([] as Todo[])
useEffect(() => {
(async () => {
const res = await api.todo.list.$get()
const result = await res.json()
if (result.success) {
setTodoList(result.data)
} else {
setTodoList([])
}
})()
}, [])
return (
<>...</>
)
}
TSXAt the end
I had some experience with Next.js, it was about 3 years back when it was still version 10, I like its concept of full-stack development, but I don’t like its design, especially when it used Webpack, which was very slow back in the day, and I didn’t need SSR. So I decided to build a private full-stack architecture based on Vite for my work. It was since then that I started to enjoy mixing things between the frontend and the backend.
I started to combine Vite and Hono together just about two weeks ago, and I really like it. They represent simplicity, fast startup, and modern design with the latest web APIs. It’s very clever that Hono built a Vite plugin that utilizes hot-reloading out of the box. People may think Bun + Elysia is the way to go with hot-reloading, but as Bun is not stable yet, and I have checked Elysia myself, I’m very confident that standing with Vite + Hono is a future-proof road at least for the next few years.
Do you have a template repo of this ?
No, I don’t have a template repo for this.
The reason why so is that this article is meant to guide readers through how the composition Vite + Hono can work and what steps are required to make them work together.
So this article includes all the steps and code examples needed to be added or modified based on the default files generated by Vite.
Readers can follow these steps to create their own examples just to copy and paste these code snippets and even add a little bit of their own, to practice muscle memories.
However, I’m considering creating a bootstrap application that will simplify the process of creating such a stack in the future.
But it still needs time to verify this composition. As the examples in the article shows, some bug (maybe) was found in the Hono Vite plugin, and I had to tweak a little bit in order to work properly.
However, what I can guarantee now is that our (my and my colleagues’) developments with this stack in our project have been working well in the past month. So it’s promised enough as far as I can tell.