hono-preact

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. server.tsx hosts the framework's __loaders / __actions handlers and the catch-all SSR handler; routes.ts declares every URL in the app and which view (and optional .server.ts module) lives at each path. The framework wires the server modules into the handlers via routeServerModules(routes).

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; <Routes> in iso.tsx 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