hono-preact v0.12.0

One framework,
edge to browser

Hono at the edge, Preact in the browser, and one typed connection between them.

The platform

Runs on the platform, at the edge.

One app on Hono, built from web standards. Write it once; it renders on the server and serves realtime the same way, whether you ship to Cloudflare Workers or Node.

Edge

One source deploys to either runtime, no rewrite between them.

Cloudflare WorkersNode

Web standards

hono-preact is a Web Fetch app on Hono.

RequestResponse

Typed edge to browser

Loader data, actions, and route params are inferred from the server all the way to the client.

LoadersActionsParams

01Routing

Routing is a manifest.

Your routes are a data structure, not a folder tree. Nested layouts stay mounted while their child swaps, and every node owns its own data and is code-split.

example.app / projects / acme
Root layoutapp shell · stays mounted
Projects layoutsidebar · stays mounted
Project layoutactive
Task viewcode-split · loads on demand
defineRoutes([
  { path: '/', layout: Shell, children: [
    { path: 'projects/:id', layout: Project, children: [
      { path: 'tasks/:taskId', view: Task },
    ] },
  ] },
]);

02SSR

SSR, no client waterfall.

Loaders run in parallel on the server and one HTML document streams down. The client never staircases through per-component fetches.

fetch in components

example.app / projects
Projects
Q3 Sales
Invoice #102000
network: fetch in components
document
root.js
data.json
sales.js
invoice.json

hono-preact SSR

example.app / projects
Projects
Q3 Sales
Invoice #102000
network: hono-preact SSR
document
loaders
hydrate.js
export const serverLoaders = {
  default: defineLoader(async ({ signal }) => getProjects({ signal })),
};
const View = serverLoaders.default.View(({ data }) =>
  data ? <List items={data} /> : <Spinner />
);
export const serverLoaders = {
  default: defineLoader(async ({ signal }) => getProjects({ signal })),
};
const View = serverLoaders.default.View(({ data }) =>
  data ? <List items={data} /> : <Spinner />
);

03Stream

Data that streams in.

A loader can be an async generator. Each value it yields streams over SSE, or inlines during SSR, and folds into the live UI the moment it lands.

/demo/projects/:projectId/tasks/:taskIdlive
  • Ship the streaming loader
  • Fold snapshots in order of arrival
  • Reconnect on drop
Live feed: open
Throughputtrending up
network: SSE
GET /feed
list
header
chart
export const serverLoaders = {
  feed: defineLoader(async function* ({ signal }) {
    while (!signal.aborted) yield await snapshot();
  }),
};
const Live = serverLoaders.feed.View(
  (s) => (s.status === 'open' ? <Count n={s.data} /> : <Connecting />),
  { initial: null, reduce: (_, snap) => snap }
);
export const serverLoaders = {
  feed: defineLoader(async function* ({ signal }) {
    while (!signal.aborted) yield await snapshot();
  }),
};
const Live = serverLoaders.feed.View(
  (s) => (s.status === 'open' ? <Count n={s.data} /> : <Connecting />),
  { initial: null, reduce: (_, snap) => snap }
);

04Action

Mutations without the cliff.

A mutation is a Form plus defineAction. The UI patches the instant you submit, then the server reconciles behind it. The row lands before the network settles.

/projects/acme
Design the landing hero
Wire up the RPC client
Design the landing herosaving
Design the landing herosaved
network: mutation + revalidate
POST /projects
POST (dup)
POST /__loaders
  • OptimisticThe new row appears the instant you submit, then reconciles when the server responds. No spinner, no dead time.
  • Race-safeSubmit again before the first finishes and the action aborts the in-flight request, so you never write a duplicate.
  • Revalidate by referenceThe action re-runs exactly the loaders it invalidates. No cache keys to name, nothing stale left on screen.
  • ProgressiveIt is a real Form bound to the action, so the same markup still submits with JavaScript disabled.
const { mutate, pending } = useAction(serverActions.addTask, {
  invalidate: 'auto',
  onMutate: (t) => addOptimistic(t),
  onError: (_e, h) => h.revert(),
});
// <Form action={serverActions.addTask}> also works with JavaScript disabled
  • OptimisticThe new row appears the instant you submit, then reconciles when the server responds. No spinner, no dead time.
  • Race-safeSubmit again before the first finishes and the action aborts the in-flight request, so you never write a duplicate.
  • Revalidate by referenceThe action re-runs exactly the loaders it invalidates. No cache keys to name, nothing stale left on screen.
  • ProgressiveIt is a real Form bound to the action, so the same markup still submits with JavaScript disabled.
const { mutate, pending } = useAction(serverActions.addTask, {
  invalidate: 'auto',
  onMutate: (t) => addOptimistic(t),
  onError: (_e, h) => h.revert(),
});
// <Form action={serverActions.addTask}> also works with JavaScript disabled

05Resilience

Built to degrade, not crash.

Loading, revalidating, and error are a discriminated union you match on: stale-while-revalidate and keep-last-good-value are the default, and a route error boundary contains a failure to its own pane.

serverLoaders.default.View((state) => {
  switch (state.status) {
    case 'loading': return <Skeleton />;
    case 'revalidating': // keeps the last value
    case 'success': return <List items={state.data} />;
    case 'error': return <Retry onRetry={useReload().reload} />;
  }
});
/demo/projects/:projectId/tasks/:taskId
revalidatingkeeps the last good value while it refetches
Overview
Tasks
Activity
reload()
reload()

06Navigation

Instant navigation.

Hover warms the cache before the click. hono-preact hands whole-page link prefetch to the browser-native Speculation Rules API, plus typed usePrefetch on any intent. The live docs site runs it.

// one line in your app config
export default defineApp({ speculation: true });
// or bind a specific link's loader to any intent (hover, focus, touch):
const prefetchIssue = usePrefetch(href, serverLoaders.issue);
See it on the live docs
example.app / dashboard
AcmeInvoices

Warming the Invoices route in parallel

  • invoices.route.jsready
  • invoices.data.jsonready
  • table.cssready
  • chart.jsready

Invoices

Opened from warm cache. No spinner.

07Transitions

Transitions, for free.

Every client route change gets a view transition, automatically. No per-link opt-in, no keyframes to hand-write.

/demo/projects/auth
Ship the auth flowWeb · In progress

The tapped card grew into the page header. One shared name, no hand-written animation.

  • AutomaticThe router wraps every client route change in a view transition. No per-link opt-in, nothing to remember.
  • Direction-awareForward navigations slide left, back slides right, keyed off nav types the framework adds for you.
  • Shared-element morphTag an element with one name and it morphs from its list card straight into the detail page header.

08Realtime

Live, both ways.

One typed duplex socket per client, with rooms and a presence roster. Use SSE when the server only pushes; reach for a WebSocket when the browser must talk back. On Cloudflare it fans out through one framework-provided Durable Object.

/demo/cursorslive
  • roster
  • cursor
  • cursor
  • typing
network: WebSocket (duplex, ongoing)
WS /__sockets
const chat = defineSocket({
  data: (c) => ({ name: c.get('user').name }),
  message: (s, m) => s.send({ text: `${s.data.name}: ${m.text}` }),
});
const { send, lastMessage, status } = chat.useSocket();

The whole surface

One package, typed throughout.

A single hono-preact install gives you the runtime, /server, /vite, and both /adapter-* targets. One dependency, typed end to end, with nothing to wire up between the pieces.

  • hono-preact
  • hono-preact/server
  • hono-preact/vite
  • hono-preact/adapter-*
import { defineRoutes } from 'hono-preact';
import { honoPreact } from 'hono-preact/vite';
import { cloudflareAdapter } from 'hono-preact/adapter-cloudflare';

Ready?

Build something that feels alive.

You have seen the whole connection: it fetches, streams, mutates, transitions, and goes live, typed the whole way. Start with the quick start, or poke at the live demo.