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
viewfield is a deferred dynamic import. The framework wraps it with preact-iso'slazyfor 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#
- 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: switch on the
LoaderStatestatus to show a spinner or skeleton while the loader fetches. - Build & Deploy: production build and deployment.