Skip to content

Instantly share code, notes, and snippets.

@MansourM61
Last active January 15, 2026 14:46
Show Gist options
  • Select an option

  • Save MansourM61/4fb2cbe63d386a30cdb5efc50a36a374 to your computer and use it in GitHub Desktop.

Select an option

Save MansourM61/4fb2cbe63d386a30cdb5efc50a36a374 to your computer and use it in GitHub Desktop.

HTMX and Alpine.js Project Setup

Hono-Based Setup

Hono is a new fast, lightweight web server framework.

Project Setup

  1. Start a new project:

    npm create hono@latest

    Select nodejs as the template and npm as package manager.

  2. Install dependencies:

    npm i handlebars
    npm i alpinejs
    npm i htmx.org
  3. Update with and add the following to tsconfig.json:

    "compilerOptions": {
        // ...
        "module": "esnext",
        // ...
    },
    "include": [
        "src/**/*",
        "./typings.d.ts",
        "templates/**/*"
    ]
  4. Create typings.d.ts in the project root folder with the following content:

    declare module "*.css";
  5. Create src/lib, src/views, templates/layout, public/css and public/js folder in the root directory.

  6. Create public/css/styles.css file with any required styles as content.

  7. Create src/lib/template-utils.ts with 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);
    };
  8. Download htmx.min.js and save it as public/js/htmx.min.js.

  9. Download latest stable version of Alpine.js from Alpine.js jsdeliver and save it as public/js/alpinejs.min.js.

  10. Create templates/layout/main.hbs with 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 Page Using JSX Notation

  1. 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);
    });
  2. Create src/views/route-jsx-element.tsx with 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;

Add a Page Using Flat HTML

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.

  1. 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));
    });
  2. Create src/views/route-html-element.tsx with 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 };
  3. Create templates/index-flat.html with the following content:

    <p>${par1}</p>
    <p>${par2}</p>

Add a Page Using handlebars

  1. 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));
    });
  2. Create src/views/route-hbs-element.tsx with 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;
  3. Create templates/index-hbs.hbs with the following content:

    <p>{{par1}}</p>
    <p>{{par2}}</p>

Vite-Based Setup

Vite is a fast frontend build tool supporting various frontend frameworks as well as server-side rendering.

Project Setup

  1. Install Vite:

    npm create vite@latest

    Select Others as the framework, Extra Vite Starters as variant, ssr-vanilla as template, and TypeScript as language.

  2. Install dependencies:

    npm i alpinejs htmx.org
    npm i --save-dev @types/alpinejs
  3. Create typings.d.ts file with the following content:

    declare module "*.css";
    declare module "*.svg";
  4. Update tsconfig.json with:

    "include": [
        //..
        "./typings.d.ts"
    ]
  5. Create src/lib/main.ts with 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();
  6. Import main.ts into the client entry script in entry-client.ts:

    import "./lib/main";
  7. Change the route definition in server.js in the project root directory from:

    app.use('*all', async (req, res) => {
        // ...
    }

    to:

    app.use('/', async (req, res) => {
        // ...
    }

Add Pages

The pages can be added in various ways including:

  1. Template literals,
  2. 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.

Hono/Vite Hybrid Setup

Another solution is to benefit Client Components (React), Server Side Rendering (JSX), HTMX, and Alpine.js.

Project Setup

The project files can be downloaded from repos.

As alternative, follow the steps below:

  1. Start a new project:

    npm create hono@latest

    Select nodejs as the template and npm as package manager.

  2. 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
  3. Update package.json with:

    "name": "Project Name",
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "vite build --mode client && vite build"
    },
  4. 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"
      }
    }
  5. 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
          }),
        ],
      };
    });
  6. 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;
  7. 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);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment