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
viewfield is a deferred dynamic import. The framework wraps it with preact-iso'slazyfor code-splitting. No manual<Route>JSX is needed;<Routes>iniso.tsxregisters 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
- The Route Table: the full
defineRoutesreference. - Layouts & Nested Routes: share chrome across URLs without remounting.
- Server Loaders: caching, cross-page invalidation, path params.
- Server Actions:
useAction, optimistic updates, file uploads, streaming. - Middleware: protect pages with the unified
usearray. - Loading States: show a fallback while the loader fetches.
- Build & Deploy: production build and deployment.