Hono is a great framework to build serverless apps with familiar APIs using Web Standards. It comes with a ton of features out of the box.
One of these features is the ability to compose & render HTML server-side, which
is great for static content.
But what if you want to add some client-side logic to your app?
You can do so by using the hooks provided by Hono. However, they might not work
in your browser — that’s because we’re not shipping any JavaScript to the
browser!
In this guide we’ll go over how to build an Hono app with client-side logic, unlocking the full potential of your projects.
What are we building?
We’re building a simple app that renders a counter component server-side and
hydrates it client-side.
It runs in Cloudflare Workers, leveraging its
static asset bindings
- though the same principles apply to the other supported environments as well.
Using Vite we’ll set up two build steps: one for the client-side logic and one for the server-side logic.
The final result: a counter component that increments a count when a button is clicked!
Let’s build!
First, let’s get started with scaffolding a new Hono app.
npm create hono@latest hono-clientyarn create hono hono-clientpnpm create hono hono-clientbunx create-hono hono-clientThe src directory contains a single index.ts file with a simple Hono app.
We’re adding a client directory with an index and component:
Directorysrc
- index.ts
Directoryclient
- index.tsx logic to mount the app on the client
- Counter.tsx component to demonstrate client-side logic
Adding the component & mounting point
Let’s start by setting up a simple counter component that increments a count when a button is clicked:
import { useState } from "hono/jsx";
export function Counter() { const [count, setCount] = useState(0);
return ( <div> <button onClick={() => setCount((c) => c + 1)} type="button"> Increase count </button> <span>Count: {count}</span> </div> );}Then we import the component & hydrate it in the client entry file:
import { StrictMode } from "hono/jsx";import { hydrateRoot } from "hono/jsx/dom/client";
import { Counter } from "./Counter";
const root = document.getElementById("root");if (!root) { throw new Error("Root element not found");}
hydrateRoot( root, <StrictMode> <Counter /> </StrictMode>);Your code editor might give you a hint that document is not defined. Given we
added the client-side logic, we need to tell TypeScript that we’re running in a
browser environment:
{ "compilerOptions": { //... "lib": [ "ESNext", "DOM" ] // ... }}We’re going to add some JSX to the src/index.ts file, so we first need to
change the file extension to .tsx.
Once that’s done, we can add Hono’s
JSX renderer middleware
to the / route and return the statically rendered <Counter /> component:
import { Hono } from "hono";import { jsxRenderer } from "hono/jsx-renderer";
import { Counter } from "./client/Counter";
const app = new Hono();
app.use( jsxRenderer( ({ children }) => ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta content="width=device-width, initial-scale=1" name="viewport" /> <title>hono-client</title> </head> <div id="root">{children}</div> </html> ), { docType: true } ));
app.get("/", (c) => { return c.text("Hello Hono!"); return c.render(<Counter />);});
export default app;We’re almost there. If you run the app now with the dev script, you’ll get an
error. Let’s fix that by adding the build steps!
Adding build steps and scripts
At this point, we have both server-side and client-side logic and need to add two build steps to our project. Let’s install Vite and two plugins to facilitate this.
npm install vitenpm install -D @hono/vite-build @hono/vite-dev-serveryarn add viteyarn add -D @hono/vite-build @hono/vite-dev-serverpnpm add vitepnpm add -D @hono/vite-build @hono/vite-dev-serverbun add vitebun add -D @hono/vite-build @hono/vite-dev-serverIn the root of your project, create a vite.config.ts file. We’ll define the
config for both the client-side build and the server-side build:
import build from "@hono/vite-build/cloudflare-workers";import devServer from "@hono/vite-dev-server";import cloudflareAdapter from "@hono/vite-dev-server/cloudflare";import { defineConfig } from "vite";
export default defineConfig(({ mode }) => { if (mode === "client") { return { build: { rollupOptions: { input: "./src/client/index.tsx", output: { entryFileNames: "assets/[name].js" } }, outDir: "./public" } }; }
const entry = "./src/index.tsx"; return { server: { port: 8787 }, plugins: [ devServer({ adapter: cloudflareAdapter, entry }), build({ entry }) ] };});Now we need to adjust the package.json scripts to facilitate the new build
steps. Additionally, we set the type to module to allow for ESM imports:
{ "name": "hono-client", "type": "module", "scripts": { "dev": "wrangler dev", "dev": "vite dev", "build": "vite build --mode client && vite build", "deploy": "wrangler deploy --minify" } // ...}Running the app
If you run the app now with the dev script, you’ll see the counter component
rendered server-side. The client-side script hasn’t been loaded yet, so the
counter component won’t work.
npm run devyarn devpnpm devbun devThere’s only one step left to make the counter component work. We’re almost there!
Load the client-side script
As a final step we need to load the client-side script in the document’s head.
For the script that we’re loading we need to make a distinction between a
development and production environment. Vite allows us to do this easily with
its built-in env.
For the dev environment we can load the client’s .tsx file; for production we
have to read it from the public directory.
First we add the vite/client types to the TypeScript config:
{ "compilerOptions": { //... "types": [ // ... "vite/client" ] //... }}Then we adjust the src/index.tsx file to load the client-side script,
depending on the environment:
// ...app.use( jsxRenderer( ({ children }) => ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta content="width=device-width, initial-scale=1" name="viewport" /> <title>hono-client</title>
<script type="module" src={ import.meta.env.PROD ? "/assets/index.js" : "/src/client/index.tsx" } /> </head> <body> <div id="root">{children}</div> </body> </html> ), { docType: true } ));// ...Run locally
Great! You can now run the app with the dev script and see the counter
component in action.
npm run devyarn devpnpm devbun devDeploying
To deploy the app to Cloudflare Workers we have to update wrangler.toml so it
points to the correct worker build & resolves the public assets directory.
Lastly, we update the deploy script.
name = "hono-client"main = "src/index.ts"main = "dist/index.js"
assets = { directory = "./public/" }# ...{ // ... "scripts": { "dev": "vite dev", "build": "vite build --mode client && vite build", "deploy": "wrangler deploy --minify", "deploy": "$npm_execpath run build && wrangler deploy --no-bundle" } // ...}Deploy your app
You can now deploy your app to Cloudflare Workers with the deploy script:
npm run deployyarn deploypnpm deploybun deployConclusion
That’s it! You’ve built a simple Hono app with client-side logic. You can now extend your app with more complex client-side features, such as fetching data from an API route, adding a form to your project, or even building a full SPA.
GitHub repo
Check out the GitHub example repo if you’d like to see the full application code. It has a few additional features, like a simple Hono RPC implementation, and a SPA example.