hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Signals
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
Session Channels
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
Listbox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition

Vite Configuration#

The hono-preact/vite subpath exports a honoPreact() plugin that configures Vite for the framework's two-pass build and dev server. Your vite.config.ts only needs to wire in the plugins that are specific to your application.

Minimal setup#

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

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

adapter is required; honoPreact() throws a clear error if it is omitted. The plugin auto-includes @preact/preset-vite internally, so you don't import it yourself. The framework ships two adapters today: cloudflareAdapter() for Cloudflare Workers, imported from hono-preact/adapter-cloudflare, and nodeAdapter() for Node.js (via @hono/node-server), imported from hono-preact/adapter-node. Each adapter supplies its own Vite plugins and entry wrapper, so the framework core stays target-agnostic.

honoPreact(options)#

OptionTypeDefaultDescription
adapterHonoPreactAdapterrequiredDeployment target, e.g. cloudflareAdapter() or nodeAdapter().
layoutstring'src/Layout.tsx'Root layout component path.
routesstring'src/routes.ts'Route table path.
apistring'src/api.ts'Optional custom routes; loaded only if the file exists.
appConfigstring'src/app-config.ts'Optional app config; loaded only if the file exists.
serverDirstring'src/server'Registry folder for route-less server modules; globbed if it exists. See The src/server registry.
clientEntrystring'virtual:hono-preact/client'Client entry module id.
css{ global?: string; autoSplit?: boolean; minSize?: number }undefinedFramework-owned global stylesheet delivery and build-time auto-split tuning. See Styling for the full pipeline.
assetsRecord<string, () => string | Uint8Array | Promise<string | Uint8Array>>undefinedExtra files to emit alongside the client build. See assets below.

assets#

assets maps an output file name to a function that produces its bytes. The key is a path relative to the client out dir, so 'llms.txt' serves at /llms.txt and a root-level key like 'sw.js' lands at the client build's root, /sw.js:

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

export default defineConfig({
  plugins: [
    honoPreact({
      adapter: cloudflareAdapter(),
      assets: {
        'llms.txt': () => renderLlmsTxt(),
      },
    }),
  ],
});

The function runs once during the build and once per request in dev, so it can read from disk or generate content dynamically and an edit shows up on the next request with no server restart. It may be sync or async, and it may return a string or a Uint8Array.

A root-level key is what makes a service worker at /sw.js possible: nothing about the file name or its position needs adapter-specific handling to reach that URL.

nodeAdapter(options?)#

OptionTypeDefaultDescription
devSsrIncludereadonly (string | RegExp)[][]Paths that must reach SSR in dev despite the dev server otherwise handing them to Vite.

In dev, the Node dev server hands two kinds of request to Vite rather than to your app. The first is anything under /@… or /node_modules/…, which is where Vite's HMR client and module graph live. The second is any path that names a real file in your project: that is how /src/routes.ts and every module and asset it pulls in reach the browser at all.

Either one can shadow a route you meant your app to serve. A /@:username profile page starts like a Vite-internal path, and a route whose URL happens to match a file on disk looks like a request for that file. Both work in production and would go missing in dev. List them under devSsrInclude to force SSR:

nodeAdapter({ devSsrInclude: [/^\/@(?!vite\/|id\/|fs\/)[^/]+$/] });

A string matches by prefix and a RegExp by test(), both against the request path with any query string removed. Everything not listed keeps the default behavior. Vite's own endpoints (its HMR client, /@id/, /@fs/, /@react-refresh) always reach Vite regardless of any pattern given here, so devSsrInclude cannot break HMR or module loading.

What the plugin handles#

honoPreact() configures everything the framework requires:

ConcernWhat it sets
Preact deduplicationresolve.dedupe for preact, preact/hooks, preact-iso, @preact/signals, @preact/signals-core (signals patches preact.options at import and must be a singleton)
Build targetbuild.target: 'esnext', build.assetsDir: 'static'
Client build outputstatic/client.js entry, hashed chunk and asset filenames in the client environment
Deployment targetDelegated to the configured adapter (Cloudflare Workers, Node.js, etc.), which supplies its own Vite plugins and entry wrapper
Server-only importsserverOnlyPlugin stubs static *.server.* imports and dynamic () => import('./*.server.*') calls in the client bundle (see Project Structure for the .server.* file convention)
Loader validationserverLoaderValidationPlugin enforces .server.* export conventions
Module identitymoduleKeyPlugin injects a path-derived __moduleKey into each .server.* file for RPC routing
Guard stripguardStripPlugin rewrites opposite-environment middleware bodies to no-ops so server-only code tree-shakes out of the client bundle
Browser shimclientShimPlugin prepends a globalThis.process ??= ... shim to the client entry so libraries reading process.env.NODE_ENV at module-eval time do not throw
CSS pipelinebuild.cssMinify: 'lightningcss' and Baseline browser targets (css.lightningcss.targets), unless you set cssMinify or css.lightningcss yourself, in which case your config wins outright. See Styling.

Peer dependencies#

Peer deps depend on the adapter you pick. Install the ones that match your deployment target.

npm install -D @cloudflare/vite-plugin wrangler

Add @hono/node-ws to the Node.js install if you want WebSocket support.

Adding MDX#

MDX is a user-space choice and not included in the plugin. Add it alongside honoPreact():

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

export default defineConfig({
  plugins: [
    honoPreact({ adapter: cloudflareAdapter() }),
    Object.assign(mdx({ jsxImportSource: 'preact' }), { enforce: 'pre' }),
  ],
});

The enforce: 'pre' placement ensures MDX files are transformed to JSX before other plugins see them.

Path aliases#

honoPreact() does not add path aliases; those belong in your config. A common setup:

import { resolve } from 'node:path';

export default defineConfig({
  resolve: {
    alias: [{ find: '@', replacement: resolve(__dirname, './src') }],
  },
  plugins: [honoPreact({ adapter: cloudflareAdapter() })],
});

Custom client entry path#

The plugin defaults to the framework-generated virtual module virtual:hono-preact/client, which hydrates <Routes> under <LocationProvider> for you. Override it only when you need a hand-written entry:

honoPreact({
  adapter: cloudflareAdapter(),
  clientEntry: 'src/main.tsx',
});

clientEntry is the single source of truth for both the rollup input and the clientShimPlugin transform target, so you only set it in one place.

A custom entry takes over the virtual entry's responsibilities. Before hydrating, call bootClient() (exported from hono-preact). It installs the client runtime services the framework relies on: the history shim (back/forward direction tracking), the navigation transition scheduler (view transitions on route changes), and the stream registry (live-loader streams). Skipping the call silently disables all three, so the dev server warns when the configured clientEntry module never references bootClient.

// src/main.tsx
import { hydrate } from 'preact';
import { LocationProvider } from 'preact-iso';
import { Routes, bootClient } from 'hono-preact';
import routes from './routes.js';

bootClient();

hydrate(
  <LocationProvider>
    <Routes routes={routes} />
  </LocationProvider>,
  document.getElementById('app')
);