hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
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
useSafeArea
usePresence

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:

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

The scaffold runs pnpm install for you. Pass --adapter=node if you're targeting Node.js instead of Cloudflare Workers; 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 src/routes.ts:

import { defineRoutes } from 'hono-preact';

export default defineRoutes([
  // ... existing routes
  { path: '/movies', view: () => import('./pages/movies.js') },
]);

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 }) => (
  <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);

Add the server field to the route entry in src/routes.ts so server.tsx finds the module:

{
  path: '/movies',
  view: () => import('./pages/movies.js'),
  server: () => import('./pages/movies.server.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, opts) returns a Preact component pre-wrapped in the loader's Suspense boundary, error context, and data context. loader.useData() inside the render function is argument-free and infers the return type from the loader function.

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 }) => (
  <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#