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)#
| Option | Type | Default | Description |
|---|---|---|---|
adapter | HonoPreactAdapter | required | Deployment target, e.g. cloudflareAdapter() or nodeAdapter(). |
layout | string | 'src/Layout.tsx' | Root layout component path. |
routes | string | 'src/routes.ts' | Route table path. |
api | string | 'src/api.ts' | Optional custom routes; loaded only if the file exists. |
appConfig | string | 'src/app-config.ts' | Optional app config; loaded only if the file exists. |
serverDir | string | 'src/server' | Registry folder for route-less server modules; globbed if it exists. See The src/server registry. |
clientEntry | string | 'virtual:hono-preact/client' | Client entry module id. |
css | { global?: string; autoSplit?: boolean; minSize?: number } | undefined | Framework-owned global stylesheet delivery and build-time auto-split tuning. See Styling for the full pipeline. |
assets | Record<string, () => string | Uint8Array | Promise<string | Uint8Array>> | undefined | Extra 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?)#
| Option | Type | Default | Description |
|---|---|---|---|
devSsrInclude | readonly (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:
| Concern | What it sets |
|---|---|
| Preact deduplication | resolve.dedupe for preact, preact/hooks, preact-iso, @preact/signals, @preact/signals-core (signals patches preact.options at import and must be a singleton) |
| Build target | build.target: 'esnext', build.assetsDir: 'static' |
| Client build output | static/client.js entry, hashed chunk and asset filenames in the client environment |
| Deployment target | Delegated to the configured adapter (Cloudflare Workers, Node.js, etc.), which supplies its own Vite plugins and entry wrapper |
| Server-only imports | serverOnlyPlugin stubs static *.server.* imports and dynamic () => import('./*.server.*') calls in the client bundle (see Project Structure for the .server.* file convention) |
| Loader validation | serverLoaderValidationPlugin enforces .server.* export conventions |
| Module identity | moduleKeyPlugin injects a path-derived __moduleKey into each .server.* file for RPC routing |
| Guard strip | guardStripPlugin rewrites opposite-environment middleware bodies to no-ops so server-only code tree-shakes out of the client bundle |
| Browser shim | clientShimPlugin prepends a globalThis.process ??= ... shim to the client entry so libraries reading process.env.NODE_ENV at module-eval time do not throw |
| CSS pipeline | build.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 wranglernpm install @hono/node-serverAdd @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')
);