hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

Quick Start#

Build a movies list with a server loader and a form action. One example covers the full route-table + view + server pattern.

Prerequisites#

Scaffold a new app. Run it with no arguments to use the interactive wizard, or pass a directory:

pnpm create hono-preact my-app
cd my-app

The wizard asks for the adapter (Cloudflare Workers or Node), whether to add hono-preact-ui components, and whether to install dependencies and init git. To script it, pass flags instead (--adapter node, --ui, --yes); with npm, put them after --. See Build & Deploy for the deployment side.

Configure Vite#

honoPreact() requires an adapter option; without one it throws a clear "adapter required" error at startup. A minimal vite.config.ts looks like:

import { honoPreact } from 'hono-preact/vite';
import { cloudflareAdapter } from 'hono-preact/adapter-cloudflare';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [honoPreact({ adapter: cloudflareAdapter() })],
});

The framework also ships a nodeAdapter() from hono-preact/adapter-node for non-Cloudflare deployments; see Build & Deploy for the full picture. See Vite Config for all honoPreact() options.

Now start the dev server:

pnpm dev

Open http://localhost:5173. The dev server runs both the Hono server and the Vite HMR client. The generated server.tsx is a one-line call to the framework's createServerEntry, which builds the /__loaders RPC, the page-action POST handler, the realtime socket upgrade, and the catch-all SSR handler on a single Hono app; routes.ts declares every URL in the app and which view (and optional .server.ts module) lives at each path.

1. Create a view#

Create src/pages/movies.tsx as a pure component with a default export:

import type { FunctionComponent } from 'preact';

const Movies: FunctionComponent = () => {
  return (
    <main>
      <h1>Movies</h1>
    </main>
  );
};

Movies.displayName = 'Movies';

export default Movies;

Add it to the routeTree array in src/routes.ts (the scaffolded file declares the tree as const and registers it, so useParams and buildPath stay typed as the table grows):

const routeTree = [
  // ... existing routes
  { path: '/movies', view: () => import('./pages/movies.js') },
] as const;

Open http://localhost:5173/movies. You should see "Movies".

The view field is a deferred dynamic import. The framework wraps it with preact-iso's lazy for code-splitting. No manual <Route> JSX is needed; the framework-generated client entry (virtual:hono-preact/client) registers everything from the manifest.

2. Add a server loader#

Create src/pages/movies.server.ts. Loaders are exported in a serverLoaders container so the view never needs to import defineLoader itself:

import { defineLoader } from 'hono-preact';

export type Movie = { id: string; title: string };

const store: Movie[] = [
  { id: '1', title: 'The Godfather' },
  { id: '2', title: 'Chinatown' },
];

const getMovies = () => store;

export const serverLoaders = {
  default: defineLoader(async () => ({
    movies: getMovies(),
  })),
};

Update src/pages/movies.tsx to consume the loader data via .View(), and pass the resulting component to definePage:

import { definePage } from 'hono-preact';
import { serverLoaders } from './movies.server.js';

const dataLoader = serverLoaders.default;

const MoviesView = dataLoader.View(({ data }) => {
  if (!data) return <p>Loading...</p>;
  return (
    <main>
      <h1>Movies</h1>
      <ul>
        {data.movies.map((m) => (
          <li key={m.id}>{m.title}</li>
        ))}
      </ul>
    </main>
  );
});

MoviesView.displayName = 'Movies';

export default definePage(MoviesView);

Register the route in src/routes.ts; the colocated movies.server.ts is wired in with it:

{
  path: '/movies',
  view: () => import('./pages/movies.js'),
}

Reload http://localhost:5173/movies. The list renders server-side on first load, with the data preloaded into the HTML. On client-side navigation away and back, the framework calls the loader over RPC. Same function, no manual wiring.

defineLoader(fn) returns a typed LoaderRef<T>. The Vite moduleKeyPlugin rewrites the call at build time to inject { __moduleKey, __loaderName } so the key drives RPC routing (/__loaders), the __id Symbol identity, and HMR. .View(render) returns a Preact component pre-wrapped in the loader's error boundary, data context, and reload context. The render function receives a LoaderState discriminated union on every render; switch on status (loading | success | revalidating | error) to render each state, and read reload from useReload(). loader.useData() inside the render function is argument-free and returns the same LoaderState.

3. Add a server action#

Add serverActions to src/pages/movies.server.ts:

import { defineAction, defineLoader } from 'hono-preact';

export type Movie = { id: string; title: string };

const store: Movie[] = [
  { id: '1', title: 'The Godfather' },
  { id: '2', title: 'Chinatown' },
];

const getMovies = () => store;
const addMovieToStore = (title: string) =>
  store.push({ id: String(store.length + 1), title });

export const serverLoaders = {
  default: defineLoader(async () => ({
    movies: getMovies(),
  })),
};

export const serverActions = {
  addMovie: defineAction<{ title: string }, { ok: boolean }>(
    async (_ctx, { title }) => {
      addMovieToStore(title);
      return { ok: true };
    }
  ),
};

Update src/pages/movies.tsx to add the form. Import serverActions alongside serverLoaders:

import type { FunctionComponent } from 'preact';
import { definePage, Form } from 'hono-preact';
import { serverLoaders, serverActions } from './movies.server.js';

const dataLoader = serverLoaders.default;

const AddMovieForm: FunctionComponent = () => (
  <Form action={serverActions.addMovie}>
    <input name="title" placeholder="Movie title" required />
    <button type="submit">Add</button>
  </Form>
);

const MoviesView = dataLoader.View(({ data }) => {
  if (!data) return <p>Loading...</p>;
  return (
    <main>
      <h1>Movies</h1>
      <AddMovieForm />
      <ul>
        {data.movies.map((m) => (
          <li key={m.id}>{m.title}</li>
        ))}
      </ul>
    </main>
  );
});

MoviesView.displayName = 'Movies';

export default definePage(MoviesView);

The route entry in routes.ts doesn't change; actions ride along with the same server import. Submit a title in the form. The action runs on the server and the list updates. To automatically re-fetch the loader after the action, pass invalidate: 'auto' via useAction (see Server Actions for the programmatic form).

What's next#