DEV Community

Oscar for Fiberplane

Posted on • Originally published at fiberplane.com

Step-by-step guide: Adding client-side logic to your Hono app

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!

We'll achieve this with client-side hydration. The app gets rendered server-side first, and then the client-side script takes over. The React docs describe it well:

[It] will "attach" your components' logic to the initial generated HTML from the server. Hydration turns the initial HTML snapshot from the server into a fully interactive app that runs in 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.

Let's build!

First, let's get started with scaffolding a new Hono app.

# Using npm
npm create hono@latest hono-client

# Using yarn
yarn create hono hono-client

# Using pnpm
pnpm create hono hono-client

# Using bun
bunx create-hono hono-client
Enter fullscreen mode Exit fullscreen mode

Make sure to select the cloudflare-workers template when prompted.

The src directory contains a single index.ts file with a simple Hono app. We're adding a client directory with an index and component:

- src
  - index.ts
  - client
    - index.tsx    # logic to mount the app on the client
    - Counter.tsx  # component to demonstrate client-side logic
Enter fullscreen mode Exit fullscreen mode

Adding the component & mounting point

Let's start by setting up a simple counter component that increments a count when a button is clicked:

// src/client/Counter.tsx
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then we import the component & hydrate it in the client entry file:

// src/client/index.tsx
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>
);
Enter fullscreen mode Exit fullscreen mode

We're hydrating the app client-side as opposed to rendering it; the static HTML is rendered server-side by Hono. If you're interested in client-side rendering only, check out the example on GitHub.

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:

// tsconfig.json
{
  "compilerOptions": {
    //...
    "lib": [
      "ESNext",
      "DOM"
    ],
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

// src/index.tsx
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.render(<Counter />);
});

export default app;
Enter fullscreen mode Exit fullscreen mode

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.

# Using npm
npm install vite
npm install -D @hono/vite-build @hono/vite-dev-server

# Using yarn
yarn add vite
yarn add -D @hono/vite-build @hono/vite-dev-server

# Using pnpm
pnpm add vite
pnpm add -D @hono/vite-build @hono/vite-dev-server

# Using bun
bun add vite
bun add -D @hono/vite-build @hono/vite-dev-server
Enter fullscreen mode Exit fullscreen mode

In 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:

// vite.config.ts
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 })
    ]
  };
});
Enter fullscreen mode Exit fullscreen mode

For the client build, the outDir is set to ./public. This is the directory where the Worker will find the client-side script.

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:

// package.json
{
  "name": "hono-client",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
    "build": "vite build --mode client && vite build",
    "deploy": "wrangler deploy --minify"
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This would be a good moment to add the public directory to your .gitignore.

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.

# Using npm
npm run dev

# Using yarn
yarn dev

# Using pnpm
pnpm dev

# Using bun
bun dev
Enter fullscreen mode Exit fullscreen mode

There'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:

// tsconfig.json
{
  "compilerOptions": {
    //...
    "types": [
      // ...
      "vite/client"
    ]
    //...
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we adjust the src/index.tsx file to load the client-side script, depending on the environment:

// src/index.tsx
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 }
  )
);
Enter fullscreen mode Exit fullscreen mode

Run locally

Great! You can now run the app with the dev script and see the counter component in action.

# Using npm
npm run dev

# Using yarn
yarn dev

# Using pnpm
pnpm dev

# Using bun
bun dev
Enter fullscreen mode Exit fullscreen mode

Deploying

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.

# wrangler.toml
name = "hono-client"
main = "dist/index.js"

assets = { directory = "./public/" }
# ...
Enter fullscreen mode Exit fullscreen mode
// package.json
{
  // ...
  "scripts": {
    "dev": "vite dev",
    "build": "vite build --mode client && vite build",
    "deploy": "$npm_execpath run build && wrangler deploy --no-bundle"
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Note that the wrangler deploy has the --no-bundle flag. The build is taken care of by the Vite build step. Wrangler's task is merely to deploy it to the Cloudflare Workers platform.

Deploy your app

You can now deploy your app to Cloudflare Workers with the deploy script:

# Using npm
npm run deploy

# Using yarn
yarn deploy

# Using pnpm
pnpm deploy

# Using bun
bun deploy
Enter fullscreen mode Exit fullscreen mode

Conclusion

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.

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.

Top comments (0)