Skip to main content

Hono and React full stack dev

Hono is an ultra-fast, lightweight, web-standard framework. Although using Hono alone for SPA development can be challenging, combining it with React enables full-stack capabilities akin to Next or Remix. Hono allows for expression not possible with React alone, such as tree structure routing. Its small bundle size and Node.js independence make it a low-cost, easily deployable option on platforms like Cloudflare. I have used Hono in some products, deploying complex features like Server-Sent Events and JWT authentication with AWS Lambda quickly.

(Sorry, my PC has broken and it's been a over year since last articles update!)

REF

getting started

To set up a Hono environment, execute the following command. I chose the x-basic template for file-based routing, but other templates facilitate global deployments on Cloudflare etc even faster.

$ npm create hono@latest

create-hono version 0.7.0
✔ Target directory … hono
✔ Which template do you want to use? › x-basic
cloned honojs/starter#main to $/glre/examples/hono
? Do you want to install project dependencies? no
🎉 Copied project files
Get started with: cd hono

diff

setup

Used vite and tailwind as references honox-playground/projects/react. add react, tailwind, etc. to dependencies in package.json.

yarn add @hono/react-renderer autoprefixer postcss react@18 react-dom@18 tailwindcss

output

// package.json
"@cloudflare/workers-types": "4",
+ "@hono/react-renderer": "latest",
"@hono/vite-cloudflare-pages": "^0.2.4",
+ "@types/react": "18",
+ "@types/react-dom": "18",
+ "autoprefixer": "10",
+ "postcss": "8",
+ "react": "18",
+ "react-dom": "18",
+ "tailwindcss": "3",

I removed "jsxImportSource": "hono/jsx" from tsconfig.json, reducing dependency on Hono's default setup (aligning more with my preferred configuration from Next.js.)

tsconfig.json

// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

To handle React SSR without errors, I added ssr.external to vite.config.ts (needs to specify all packages that cannot be run on the server)

// vite.config.ts
...
return {
+ ssr: {
+ external: ['react', 'react-dom'],
+ },
plugins: [honox(), pages()],
}
...

support react

I transitioned from using hono/jsx to React by integrating @hono/react-renderer. (In addition to react, any UI library such as preact or solid can be used.)

// app/routes/_rerender.tsx
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
const src = import.meta.env.PROD
? '/static/client.js'
: '/app/client.ts'
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src={src}></script>
</head>
<body>{children}</body>
</html>
)
})

docs

React case

You can define a renderer using @hono/react-renderer. Install the modules first.

npm i @hono/react-renderer react react-dom hono
npm i -D @types/react @types/react-dom

Define the Props that the renderer will receive in global.d.ts.

// global.d.ts
import '@hono/react-renderer'

declare module '@hono/react-renderer' {
interface Props {
title?: string
}
}

The following is an example of app/routes/_renderer.tsx.

// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
return (
<html lang='en'>
<head>
<meta charSet='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
{import.meta.env.PROD ? (
<script type='module' src='/static/client.js'></script>
) : (
<script type='module' src='/app/client.ts'></script>
)}
{title ? <title>{title}</title> : ''}
</head>
<body>{children}</body>
</html>
)
})

The app/client.ts will be like this.

// app/client.ts
import { createClient } from 'honox/client'

createClient({
hydrate: async (elem, root) => {
const { hydrateRoot } = await import('react-dom/client')
hydrateRoot(root, elem)
},
createElement: async (type: any, props: any) => {
const { createElement } = await import('react')
return createElement(type, props)
},
})

Following the dcos, I updated client.tsx to enable React's hydration and rendering capabilities. (Currently, a lot of Type Errors are output, so we need to modify hono's code in the future.)

// app/client.tsx
import { createClient } from 'honox/client'

- createClient()

+ createClient({
+ hydrate: async (elem, root) => {
+ const { hydrateRoot } = await import('react-dom/client')
+ hydrateRoot(root, elem)
+ },
+ createElement: async (type: any, props: any) => {
+ const { createElement } = await import('react')
+ return createElement(type, props)
+ },
+ })

I removed hono/css from router/index.ts, opting instead for Tailwind.

// app/routes/index.ts
- import { css } from 'hono/css'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'

- const className = css`
- font-family: sans-serif;
- `

export default createRoute((c) => {
const name = c.req.query('name') ?? 'Hono'
return c.render(
- <div class={className}>
+ <div>
<h1>Hello, {name}!</h1>
<Counter />
</div>,

I switched from hono/jsx to React in islands/counter.tsx, enabling the existing demo to run using React. Starting the server with yarn dev activates these changes.

// app/islands/counter.tsx
- import { useState } from 'hono/jsx'
+ import { useState } from 'react'

diff

Support tailwind

To utilize Tailwind CSS with Hono, create the tailwind.config.js, postcss.config.js, and app/style.css. (Next.js as well as always, but the document also shows how to do it!)

// tailwind.config.js
export default {
content: ['./app/**/*.tsx'],
theme: {
extend: {},
},
plugins: [],
}
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
/* app/style.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

docs

Using Tailwind CSS

Given that HonoX is Vite-centric, if you wish to utilize Tailwind CSS, simply adhere to the official instructions.

Prepare tailwind.config.js and postcss.config.js:

// tailwind.config.js
export default {
content: ['./app/**/*.tsx'],
theme: {
extend: {},
},
plugins: [],
}
// postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Write app/style.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

Finally, import it in a renderer file:

// app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children }) => {
return (
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
{import.meta.env.PROD ? (
<link href='static/assets/style.css' rel='stylesheet' />
) : (
<link href='/app/style.css' rel='stylesheet' />
)}
</head>
<body>{children}</body>
</html>
)
})

Modify _renderer.tsx so that tailwind css is loaded.

// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
+ const href = import.meta.env.PROD
+ ? 'static/assets/style.css'
+ : '/app/style.css'
...
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
+ <link href={href} rel="stylesheet" />
<script type="module" src={src}></script>
...

Fix app/routes/index.ts to eliminate hono/css and utilize Tailwind classes instead as sample.

// app/routes/index.ts
export default createRoute((c) => {
const name = c.req.query('name') ?? 'Hono'
return c.render(
- <div>
+ <div className="font-sans">

Fix vite.config.json and add a setting to build tailwind at production build time.

// vite.config.js
export default defineConfig(({ mode }) => {
if (mode === 'client') {
return {
+ build: {
+ rollupOptions: {
+ input: ['/app/style.css'],
+ output: {
+ assetFileNames: 'static/assets/[name].[ext]'
+ }
+ }
+ },
plugins: [client()],
}
...

diff

add d1 sqlite

Build a Comments API · Cloudflare D1 docs 's official docs and honox-playground/projects/cloudflare-bindings 's code may be used as a reference for implementation. After local setup such as login on the terminal of wrangler, execute the following cmd, where xxx is the database_name of your choice. A text will be generated and please save as wrangler.toml.

npx wrangler d1 create xxx

output

# wrangler.toml
[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "xxx"
database_id = "yyyyyyyyyyyyyyyyyyyyyyyy"

Define the database schema for Cloudflare D1 in dump.sql and set up the tables and initial data configuration. To use D1 from Cloudflare Pages / Worker in a prod env, select Settings/Function/D1 database bindings from the Cloudflare Console and specify DB for the Variable name property, D1 database property with xxx (the database_name you created).

  • npx wrangler d1 execute xxx --local --file=./app/schemas/dump.sql. (xxxx must be the name you specify. If you remove --local, it will be deployed in Cloudflare.)
  • npx wrangler d1 execute xxx --local --command='SELECT * FROM creation' (it can check database)
DROP TABLE IF EXISTS `creation`;
CREATE TABLE `creation` (
`id` TEXT PRIMARY KEY,
`title` TEXT DEFAULT NULL,
`content` TEXT DEFAULT NULL,
`created_at` TEXT DEFAULT (datetime('now')),
`updated_at` TEXT DEFAULT (datetime('now'))
);
INSERT INTO `creation` (id, title, content) VALUES ('a_id', 'a_title', 'a_content');
INSERT INTO `creation` (id, title, content) VALUES ('b_id', 'b_title', 'b_content');
INSERT INTO `creation` (id, title, content) VALUES ('c_id', 'c_title', 'c_content');

output

┌──────┬─────────┬───────────┬─────────────────────┬─────────────────────┐
│ id │ title │ content │ created_at │ updated_at │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ a_id │ a_title │ a_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ b_id │ b_title │ b_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ c_id │ c_title │ c_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │
└──────┴─────────┴───────────┴─────────────────────┴─────────────────────┘

Modify vite.config.ts to configure wrangler settings during the development build process. (I ran a bug where vite would clash if I didn't add .mf to server.watch.ignored, but after submitting a fix PR, it was merged immediately 🎉)

// vite.config.ts
...

+ import { getPlatformProxy } from 'wrangler'

- export default defineConfig(({ mode }) => {
+ export default defineConfig(async ({ mode }) => {
if (mode === 'client') {
...
} else {
+ const { env, dispose } = await getPlatformProxy();
return {
ssr: {
external: ['react', 'react-dom'],
},
- plugins: [honox(), pages()],
+ plugins: [
+ honox({
+ devServer: {
+ env,
+ plugins: [{ onServerClose: dispose }],
+ },
+ }),
+ pages(),
+ ],
}

diff

make service

Query D1 from Hono · Cloudflare D1 docs's official docs and honox-examples/projects/blog at main · yusukebe/honox-examples's code could be used as a reference for implementation. Three routes app/routes/index.tsx , app/routes/new.tsx and app/routes/[userId]/[id]/index.tsx were created for the api to create and update the shader text.

code

// app/routes/index.tsx
import { createRoute } from 'honox/factory'
import { cors } from 'hono/cors'
import App from '../islands/home'

export const GET = createRoute(cors(), async (c) => {
const { results } = await c.env?.DB?.prepare?.(
`select * from creation`
).all()
const creationItems = results as any[]
return c.render(<App creationItems={creationItems} />)
})
// app/routes/new.tsx
import { z } from 'zod'
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { cors } from 'hono/cors'
import App from '../islands/new'

export const GET = createRoute(cors(), async (c) => {
return c.render(<App />)
})

const schema = z.object({
title: z.string().min(1),
content: z.string().min(1),
})

export const POST = createRoute(
cors(),
zValidator('json', schema, (result, c) => {
if (!result.success) return c.render('Error')
}),
async (c) => {
// const name = c.req.query('name') ?? 'hono'
const { title, content } = c.req.valid('json')
const id = crypto.randomUUID()
const { success } = await c.env?.DB?.prepare?.(
`INSERT INTO creation (id, title, content) VALUES (?, ?, ?)`
)
.bind(id, title, content)
.run()
if (success) {
c.status(201)
return c.json({ id })
} else {
c.status(500)
return c.json({ message: 'Something went wrong' })
}
}
)

export type CreateAppType = typeof POST
// app/routes/[userId]/[id]/index.tsx
import { z } from 'zod'
import { createRoute } from 'honox/factory'
import { basicAuth } from 'hono/basic-auth'
import { zValidator } from '@hono/zod-validator'
import { cors } from 'hono/cors'
import App from '../../../islands/edit'

const AUTH = basicAuth({
username: 'username',
password: 'password',
})

export const GET = createRoute(cors(), AUTH, async (c) => {
const { id } = c.req.param()
// @ts-ignore
const { results } = await c.env?.DB?.prepare?.(
`select * from creation where id = ?`
)
.bind(id)
.all()
const item = results[0]

return c.render(
<App
creationId={id}
creationTitle={item.title}
creationContent={item.content}
/>
)
})

const schema = z.object({
title: z.string().min(1),
content: z.string().min(1),
})

export const PUT = createRoute(
cors(),
zValidator('json', schema, (result, c) => {
if (!result.success) {
return c.render('Error', {
hasScript: true,
})
}
}),
async (c) => {
const { id } = c.req.param()
const { title, content } = c.req.valid('json')
const { success } = await c.env?.DB?.prepare?.(
`UPDATE creation SET title = ?, content = ? WHERE id = ?`
)
.bind(title, content, id)
.run()
if (success) {
c.status(201)
return c.json({ id })
} else {
c.status(500)
return c.json({ message: 'Something went wrong' })
}
}
)

export const DELETE = createRoute(cors(), async (c) => {
const { id } = c.req.param()
const { success } = await c.env?.DB?.prepare?.(
`DELETE FROM creation WHERE id = ?`
)
.bind(id)
.run()
if (success) {
c.status(201)
return c.json({ message: 'Deleted' })
} else {
c.status(500)
return c.json({ message: 'Something went wrong' })
}
})
update ui

I created a base design using vercel's v0.dev, a service that generates ui with ai. I asked chatgpt to come up with the prompt for v0.dev. (The output code should look good if you make it look good!) Complete by executing npm run deploy to deploy your application! Cloudflare's deployment speed stands out compared to other services like AWS, which can take several minutes. This rapid deployment capability is one of the reasons I particularly enjoy using Cloudflare.

↑ It can be played on play.glre.dev 💪