Hono is a new fast, lightweight web server framework.
-
Start a new project:
npm create hono@latest
Select
nodejsas the template andnpmas package manager. -
Install dependencies:
npm i handlebars npm i alpinejs npm i htmx.org
-
Update with and add the following to
tsconfig.json:"compilerOptions": { // ... "module": "esnext", // ... }, "include": [ "src/**/*", "./typings.d.ts", "templates/**/*" ]
-
Create
typings.d.tsin the project root folder with the following content:declare module "*.css";
-
Create
src/lib,src/views,templates/layout,public/cssandpublic/jsfolder in the root directory. -
Create
public/css/styles.cssfile with any required styles as content. -
Create
src/lib/template-utils.tswith the following content:import path from "path"; import fs from "fs"; import Handlebars from "handlebars"; import HandlebarsTemplateDelegate from "handlebars"; const templateDir = path.join(process.cwd(), "templates"); const loadFile = (fileName: string[], extension: string): string => fs.readFileSync( path.join(templateDir, ...fileName) + "." + extension, "utf8" ); export const loadRawTemplate = (page: string[]): string => loadFile(page, "html"); // https://stackoverflow.com/a/410 15840 export function interpolate( tempStr: string, params: { [x: string]: unknown } ) { const names = Object.keys(params); const vals = Object.values(params); const sanitizedStr = tempStr; return new Function(...names, `return \`${sanitizedStr}\`;`)(...vals); } export const loadHBSTemplate = ( page: string[], options?: { [x: string]: unknown } ): HandlebarsTemplateDelegate<unknown> => { const templateString = loadFile(page, "hbs"); return Handlebars.compile(templateString, options); }; export const loadLayoutHBSTemplate = ( page: string[], options?: { [x: string]: unknown } ): HandlebarsTemplateDelegate<unknown> => { const pageFile = page.slice(); const pageString = loadFile(pageFile, "hbs"); const layoutFile = page.slice(0, -2); layoutFile.push("layout", "main"); const layoutString = loadFile(layoutFile, "hbs"); const fullPageString = layoutString.replace("{{{body}}}", pageString); return Handlebars.compile(fullPageString, options); };
-
Download htmx.min.js and save it as
public/js/htmx.min.js. -
Download latest stable version of Alpine.js from Alpine.js jsdeliver and save it as
public/js/alpinejs.min.js. -
Create
templates/layout/main.hbswith the following content:<html> <head> <meta charset="utf-8" /> <title>Example App</title> <link rel="stylesheet" href="/css/styles.css" /> <script defer src="/js/alpinejs.min.js"></script> <script defer src="/js/htmx.min.js"></script> </head> <body> {{{body}}} </body> </html>
-
Add a new route to
src/index.ts:import routeJSXElement from "./views/route-jsx-element"; // ... app.get("/route", (c) => { const data = loadData(); return routeJSXElement(c, data); });
-
Create
src/views/route-jsx-element.tsxwith the following content:import type { FC } from "hono/jsx"; const Layout: FC = (props) => { return ( <html> <body>{props.children}</body> </html> ); }; const Top: FC<{ data: string[] }> = (props: { data: string[] }) => { return ( <Layout> <h1>JSX Style</h1> <ul> {props.data.map((item) => { return <li>{item}</li>; })} </ul> </Layout> ); }; const routeJSXElement = ( c: object & { html: Function }, messages: string[] ) => c.html(<Top messages={messages} />); export default routeJSXElement;
This technique is flat HTML because there is no nesting, looping, condition, etc features supported in the HTML. It is only a simple variable substitution.
-
Add a new route to
src/index.ts:import * as routeHTMLElement from "./views/route-html-element"; // ... app.get("/temp", (c) => { const data = { par1: val1, par2: val2 }; return c.html(routeHTMLElement.compiledTemplate(data)); });
-
Create
src/views/route-html-element.tsxwith the following content:import { loadRawTemplate, interpolate } from "../lib/template-utils"; const PAGE = "index-flat"; const templateString = loadRawTemplate([PAGE]); const compiledTemplate = (param: { [x: string]: unknown }) => interpolate(templateString, param); export { templateString, compiledTemplate };
-
Create
templates/index-flat.htmlwith the following content:<p>${par1}</p> <p>${par2}</p>
-
Add a new route to
src/index.ts:import * as routeHBSElement from "./views/route-hbs-element"; // ... app.get("/temp", (c) => { const data = { par1: val1, par2: val2 }; return c.html(routeHBSElement.compiledTemplate(data)); });
-
Create
src/views/route-hbs-element.tsxwith the following content:import { loadLayoutHBSTemplate } from "../lib/template-utils"; const pageTemplate = loadLayoutHBSTemplate(["index-hbs"]); const routeHBSElement = (param: { [x: string]: unknown }) => pageTemplate(param); export default routeHBSElement;
-
Create
templates/index-hbs.hbswith the following content:<p>{{par1}}</p> <p>{{par2}}</p>
Vite is a fast frontend build tool supporting various frontend frameworks as well as server-side rendering.
-
Install Vite:
npm create vite@latest
Select
Othersas the framework,Extra Vite Startersas variant,ssr-vanillaas template, andTypeScriptas language. -
Install dependencies:
npm i alpinejs htmx.org npm i --save-dev @types/alpinejs
-
Create
typings.d.tsfile with the following content:declare module "*.css"; declare module "*.svg";
-
Update
tsconfig.jsonwith:"include": [ //.. "./typings.d.ts" ]
-
Create
src/lib/main.tswith the following content:import Alpine from "alpinejs"; import htmx from "htmx.org"; declare global { interface Window { Alpine: typeof Alpine; htmx: typeof htmx; } } window.Alpine = Alpine; window.htmx = htmx; Alpine.start();
-
Import
main.tsinto the client entry script inentry-client.ts:import "./lib/main";
-
Change the route definition in
server.jsin the project root directory from:app.use('*all', async (req, res) => { // ... }
to:
app.use('/', async (req, res) => { // ... }
The pages can be added in various ways including:
- Template literals,
- Handlebars templates.
All Alpine.js and htmx attributes are valid as long as they are implemented at client side with the script importing src/lib/main.ts.
Another solution is to benefit Client Components (React), Server Side Rendering (JSX), HTMX, and Alpine.js.
The project files can be downloaded from repos.
As alternative, follow the steps below:
-
Start a new project:
npm create hono@latest
Select
nodejsas the template andnpmas package manager. -
Install dependencies:
npm i @hono/node-server npm i alpinejs htmx npm i -D @hono/vite-build @hono/vite-dev-server @types/node vite npm i --save-dev @types/alpinejs
-
Update
package.jsonwith:"name": "Project Name", "type": "module", "scripts": { "dev": "vite", "build": "vite build --mode client && vite build" },
-
Create
tsconfig.json:{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx" } } -
Create
vite.config.ts:import devServer from "@hono/vite-dev-server"; import { defineConfig } from "vite"; // Change the import to use your runtime specific build import build from "@hono/vite-build/node"; export default defineConfig(({ mode }) => { // client side if (mode === "client") return { esbuild: { jsxImportSource: "hono/jsx/dom", // Optimized for hono/jsx/dom }, build: { rollupOptions: { input: "./src/client.tsx", // name of the client-side pages output: { entryFileNames: "static/client.js", // name of the client pages after build }, }, }, }; // server side return { plugins: [ build({ entry: "src/index.tsx", // name of the server script used for build }), devServer({ entry: "src/index.tsx", // name of the server script used for development }), ], }; });
-
Create
src/index.tsx:import { Hono } from "hono"; const app = new Hono(); // create the server object that takes care of the routes, etc export type AppType = typeof routes; // used to establish RPC (remote procedure call) between client and server. It is based on the routes that are exposed to the client // API end point to respond to GET const routes = app.get("/api/clock", (c) => { return c.json({ time: new Date().toLocaleTimeString(), }); }); // API end point to respond to htmx-generated event app.get("/messages", (c) => { return c.html("This is the message!"); }); // Landing page: entry to the client-side component app.get("/", (c) => { return c.html( <html lang="en"> <head> <meta charSet="utf-8" /> <meta content="width=device-width, initial-scale=1" name="viewport" /> <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" /> {import.meta.env.PROD ? ( // script to run during production (based on the output of rollup specified in `vite.config.ts` file) <script type="module" src="/static/client.js" /> ) : ( // script to run during development (based on the output of rollup specified in `vite.config.ts` file) <script type="module" src="/src/client.tsx" /> )} </head> <body> {/* root element that will be replaced by the react root component. */} <div id="root" /> </body> </html> ); }); export default app;
-
Create
src/client.tsx:import { hc } from "hono/client"; import { useState } from "hono/jsx"; import { render } from "hono/jsx/dom"; import type { AppType } from "."; import { html, raw } from "hono/html"; import Alpine from "alpinejs"; import htmx from "htmx.org"; // Alpine.js and htmx declare global { interface Window { Alpine: typeof Alpine; htmx: typeof htmx; } } window.Alpine = Alpine; window.htmx = htmx; Alpine.start(); const client = hc<AppType>("/"); // create this client with the server URL as argument // Alpine.js-based component const AlpineJS = () => ( <div x-data="{ count: 0 }"> <button x-on:click="count++">Increment</button> {" "} <span x-text="count"></span> </div> ); // HTMX-based component const HTMX = () => ( <button hx-get="/messages" hx-trigger="click" hx-swap="outerHTML"> Click </button> ); // the root component to replace root element of HTML code in the server code `index.tsx` function App() { return ( <> <h1>Hello hono/jsx/dom!</h1> <h2>Example of useState()</h2> <Counter /> <h2>Example of API fetch()</h2> <ClockButton /> <h2>Example of Alpine.js()</h2> <AlpineJS /> <h2>Example of HTMX()</h2> <HTMX /> </> ); } // client-side component (fully executed on client with no dependency on server) function Counter() { const [count, setCount] = useState(0); return ( <button type="button" onClick={() => setCount(count + 1)}> You clicked me {count} times </button> ); } // client-side component (dependent on an API end point of the server) const ClockButton = () => { const [response, setResponse] = useState<string | null>(null); // button click handler function const handleClick = async () => { const response = await client.api.clock.$get(); // client.{path}.{method} with a `fetch` compatible response const data = await response.json(); const headers = Array.from(response.headers.entries()).reduce< Record<string, string> >((acc, [key, value]) => { acc[key] = value; return acc; }, {}); const fullResponse = { url: response.url, status: response.status, headers, body: data, }; setResponse(JSON.stringify(fullResponse, null, 2)); }; return ( <div> <button type="button" onClick={handleClick}> Get Server Time </button> {response && <pre>{response}</pre>} </div> ); }; // replace the root element in the HTML code given in `index.tsx` const root = document.getElementById("root")!; render(<App />, root);