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
examples
- honojs/honox: HonoX
- yusukebe/honox-playground
- yusukebe/honox-examples: HonoX examples
- yusukebe (yusukebe) / Repositories
articles
- hono / honox について
- d1 について
- hono + d1 について
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
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-domDefine 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'
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
andpostcss.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()],
}
...
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(),
+ ],
}
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 💪