Allister Antosik logo
Search posts & pages…⌘K
10 min read

Hono + Datastar: A Future-Resilient TypeScript Web Stack

#Hono#Datastar#TypeScript#Hypermedia#Web Development

I keep coming back to the same source of web application pain: the button is easy, but keeping five versions of its state in sync is not.

The database model, API schema, client store, query cache and component tree all need to agree. Then the ecosystem moves and one layer has to be replaced.

Hono and Datastar offer a calmer default.

Hono + Datastar is a server-driven TypeScript stack. Hono handles portable HTTP routing and HTML rendering, while Datastar adds browser reactivity and server-pushed updates without requiring a thick client application.

I would not call any stack future-proof. That phrase usually ages badly. Future-resilient feels fairer because the important seams here are HTML, HTTP and Server-Sent Events (SSE).

What are Hono and Datastar?

Hono is a small HTTP framework built around Web Standard Request and Response APIs. It runs on Node.js, Bun, Deno, Cloudflare Workers, AWS Lambda, Vercel and other JavaScript runtimes.

It is not trying to be an all-encompassing application platform. It gives you routing, middleware, a context API and server-side rendering through JSX or its escaped html helper.

Datastar is a hypermedia framework. It enhances HTML with declarative data-* attributes for local reactivity, events and backend actions.

A button can call @get() or @post(), and Datastar can handle four useful response types:

  • text/html patches elements into the page.
  • application/json patches reactive signals.
  • text/event-stream carries any number of DOM or signal patches over time.
  • text/javascript is an explicit escape hatch for browser-side code.

These response types are part of Datastar’s action model. Each interaction can use the least complicated response instead of inheriting one architecture for the entire app.

The division of responsibility is refreshingly ordinary:

  • The server owns durable state, authorisation, validation and HTML rendering.
  • The browser owns short-lived UI state, such as an open menu or a pending request.
  • HTML is the default representation across the boundary.
  • SSE appears only for progress, live updates or genuinely streaming work.

That is what drew me to the pairing. Hono is good at producing standards-based responses; Datastar is deliberately good at consuming server-produced HTML and events.

Start with the boring path

Consider a small task list. It needs server rendering, an enhanced form, partial page updates and one long-running operation.

There is no application-specific client bundle to compile, no duplicated client store and no separate JSON API to design.

Create a Hono project and choose the Node.js template:

npm create hono@latest hono-datastar-demo
cd hono-datastar-demo
npm install
npm run dev

Replace the starter with this src/index.ts:

import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { html } from 'hono/html';
import { streamSSE } from 'hono/streaming';

type Task = {
  id: number;
  title: string;
};

// Demo storage only. Use a durable store in a real deployment.
let nextId = 3;
let tasks: Task[] = [
  { id: 1, title: 'Instrument the checkout path' },
  { id: 2, title: 'Review the latency SLO' },
];

const app = new Hono();

const TaskList = () => html`
  <section id="tasks" aria-live="polite">
    <h2>Tasks</h2>
    <ul>
      ${tasks.map(
        (task) => html`
          <li id="task-${task.id}">
            <span>${task.title}</span>
            <button
              type="button"
              data-on:click="@delete('/tasks/${task.id}')"
            >
              Done
            </button>
          </li>
        `,
      )}
    </ul>
  </section>
`;

const Page = () => html`<!doctype html>
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>Hono + Datastar</title>
      <script
        type="module"
        src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.2/bundles/datastar.js"
      ></script>
    </head>
    <body>
      <main>
        <h1>Operations backlog</h1>

        <form
          method="post"
          action="/tasks"
          data-on:submit="@post('/tasks', {contentType: 'form'})"
          data-indicator:creating
        >
          <label>
            New task
            <input name="title" required autocomplete="off" />
          </label>
          <button type="submit" data-attr:disabled="$creating">
            Add task
          </button>
          <span data-show="$creating" style="display: none">Saving…</span>
        </form>

        ${TaskList()}

        <h2>Streaming work</h2>
        <button type="button" data-on:click="@post('/maintenance/reindex')">
          Rebuild the search index
        </button>
        <p id="job-status" aria-live="polite">Idle.</p>
      </main>
    </body>
  </html>`;

app.get('/', (c) => c.html(Page()));

app.post('/tasks', async (c) => {
  const body = await c.req.parseBody();
  const title = String(body.title ?? '').trim();

  if (!title) {
    return c.text('A title is required', 400);
  }

  tasks = [{ id: nextId++, title }, ...tasks];

  // Datastar asks for the fragment. A normal form submission gets PRG.
  if (c.req.header('Datastar-Request') === 'true') {
    return c.html(TaskList());
  }

  return c.redirect('/', 303);
});

app.delete('/tasks/:id', (c) => {
  const id = Number(c.req.param('id'));
  tasks = tasks.filter((task) => task.id !== id);
  return c.html(TaskList());
});

app.post('/maintenance/reindex', (c) =>
  streamSSE(c, async (stream) => {
    await stream.writeSSE({
      event: 'datastar-patch-elements',
      data: 'elements <p id="job-status" aria-live="polite">Scanning…</p>',
    });

    await stream.sleep(750);

    await stream.writeSSE({
      event: 'datastar-patch-elements',
      data: 'elements <p id="job-status" aria-live="polite">Complete.</p>',
    });
  }),
);

serve({ fetch: app.fetch, port: 3000 });

export default app;

The example has three levels of interaction:

  1. GET / returns a complete server-rendered document.
  2. Creating or deleting a task returns an HTML fragment. Its top-level id="tasks" tells Datastar what to morph.
  3. The maintenance action returns one SSE stream and patches #job-status more than once.

Datastar’s element-patch protocol is intentionally small. Hono’s streamSSE() helper handles the transport.

For a larger app, I would hide event formatting behind a small adapter or use the optional Datastar TypeScript SDK. Route handlers should not become protocol string factories.

The CDN keeps this demo easy to paste and run. Datastar recommends self-hosting its client file in production, so pin a tested version, serve it with your static assets and upgrade it deliberately.

Why does the stack stay flexible?

The flexibility comes from keeping application boundaries on slow-moving web standards and adding client complexity one interaction at a time.

It does not mean moving between runtimes or frameworks is free. It means those changes are less likely to drag the domain model and every screen along with them.

Runtime portability lives at the server boundary

A Hono route is fundamentally a function from a request to a response. Moving from Node.js to Bun, a container to an edge runtime, or one serverless provider to another still takes infrastructure work.

It may also require a new entry point. It should not normally require a new routing and rendering model.

Storage is the obvious catch. The in-memory array above is unsuitable for horizontal scaling, serverless execution or edge deployment.

Keep persistence behind a repository or service boundary. A provider-specific database binding should not leak into every route.

Add complexity one interaction at a time

A single-page application often pays for hydration, client routing, API contracts and cache synchronisation before the first complex interaction exists.

Hono and Datastar let the application grow in smaller steps:

  1. Return a complete HTML page.
  2. Return an HTML fragment for an enhanced form.
  3. Add local signals for transient presentation state.
  4. Stream patches with SSE when an operation has multiple results over time.
  5. Use a web component or focused client module when the work truly belongs in the browser.

That progression matters more to me than winning a bundle-size comparison. It gives every feature an honest complexity budget.

Keep the server as the source of truth

Datastar sends signals with backend actions and can patch signals or elements in response. Durable data can stay authoritative on the server instead of gaining a second, long-lived client model.

There is still coupling. The backend needs to know which DOM elements it can replace, so stable element IDs become part of the interface contract.

That contract is usually easier to inspect than a mix of API schemas, query keys, reducers and component state. It still deserves deliberate design and tests.

Keep escape hatches open

Not every Datastar interaction needs SSE. Plain HTML and JSON work too, and Hono can expose a conventional JSON API beside its HTML routes.

Existing JavaScript libraries and web components can live on the same page. data-ignore and data-ignore-morph tell Datastar to leave a managed subtree alone.

You can still add a mobile API, a specialist visualisation or a later service boundary. The durable domain layer should not care whether its result becomes HTML, JSON or an event stream.

Operate SSE like a long-lived workload

This is where my SRE brain starts asking awkward questions. Server-driven does not mean operationally free.

Most behaviour remains visible as HTTP routes, so request metrics, traces, structured logs, request IDs and server-side profiling still apply.

SSE has a different resource profile. I would track at least:

  • active and peak stream counts;
  • connection duration and disconnect reason;
  • time to first event;
  • events and bytes per connection;
  • application errors after streaming has started; and
  • proxy buffering, idle timeouts and load-balancer connection limits.

Once streaming begins, the route cannot swap in a normal error response. Hono notes that its ordinary onError hook cannot rewrite a response after the stream has started.

Streaming handlers need their own error logging, cancellation handling and a meaningful terminal UI state. A spinner that waits forever is not an error strategy.

Hono’s request and response model is also pleasant to test. Its documented app.request() method exercises routes without starting a server.

Test full pages and fragments at that level. Treat event names and payloads as a protocol contract, then keep a small browser suite for morphing, focus preservation and accessibility.

Production guardrails

A smaller architecture does not remove the usual web security work.

  • Treat Datastar signals as untrusted input. They are visible and user-modifiable, and the security guide warns against putting secrets in them.
  • Escape user-controlled content. Hono’s html helper escapes interpolated task titles, but raw HTML and Datastar expressions still need care.
  • Apply authentication and authorisation in the route or service layer, not in the rendered UI.
  • Protect state-changing requests from CSRF. Hono’s middleware checks form-compatible content types, while Datastar sends JSON by default. Use an origin check or token strategy that covers the requests you send.
  • Put rate and body-size limits in front of expensive or streaming handlers.
  • Pin and self-host Datastar, set a deliberate Content Security Policy and exercise upgrades through automated tests.

When is Hono + Datastar a good fit?

Hono and Datastar fit applications where the server owns most state but the interface still needs responsive updates. CRUD products, internal tools, live dashboards and streamed background jobs are natural examples.

I would shortlist the stack for:

  • CRUD-heavy products and internal platforms;
  • operational consoles and observability interfaces;
  • dashboards with live status updates;
  • background jobs with streamed progress;
  • chat, notifications and collaborative server-owned views; and
  • small teams that want one TypeScript rendering model without a separate frontend app.

It is a less obvious default for offline-first or local-first products, browser IDEs, graphics-heavy experiences, or applications whose real complexity lives in rich client state.

Does Hono + Datastar replace React, Vue or Svelte?

No. A mature client framework may be the economical choice when a product depends on a deep component ecosystem, specialist frontend skills or complex offline state.

The point is not that hypermedia replaces every frontend architecture. It is that many applications commit to a thick client before proving they need one.

Future-proof the seams, not the framework

The most durable Hono and Datastar codebases will keep a few rules:

  1. Keep domain logic independent of Hono contexts and HTML rendering.
  2. Render pages and replaceable fragments with small, pure functions.
  3. Give morph targets stable, meaningful IDs.
  4. Put Datastar SSE formatting behind one adapter when it appears in more than a few routes.
  5. Preserve form actions or JSON endpoints where graceful fallback and external clients matter.
  6. Treat storage, queues and identity as explicit platform boundaries.
  7. Observe SSE as a long-lived workload rather than an ordinary short request.

Hono and Datastar appeal to me because they minimise how much architecture must be invented up front.

Hono gives the server a portable, standards-based core. Datastar lets that server deliver a reactive interface without duplicating the application into a second stateful system in the browser.

That is the useful meaning of future-resilient: not pretending these libraries will never change, but resting the valuable code on slower-moving foundations so the whole product does not need to move with them.