Chevalier (knight in French) is a small, file-routed Deno meta-framework that renders with Preact and ships islands, not bundles.
It keeps Hono for the HTTP layer and brings a Preact view layer. A page with no islands ships zero client JavaScript. Adding an island never grows a page that doesn't use it.
// app/routes/index.tsx → GET /
import Counter from "../islands/counter.tsx";
export default function Home() {
return (
<main>
<h1>Chevalier</h1>
<Counter start={3} /> {/* the only JS this page ships */}
</main>
);
}- 🗂️ File-based routing.
routes/index.tsx→/,blog/[slug].tsx→/blog/:slug,docs/[...rest].tsxcatch-all. Familiar conventions, no config. - 🏝️ Per-page islands. Each page ships only the JS for the islands it
actually rendered. No islands, no
<script>. An island is declared by path, never a wrapper in your code. - ⚡ Split hot-reload. Islands hot-update in place with state preserved (Preact Fast Refresh). Route and layout edits force a full reload.
- 🔥 Hono all the way down. The HTTP layer stays Hono.
Any route file can
export const appto serve any method, as a Hono sub-app. - 🦕 Deno-native. JSR package, Vite dev server, no
package.json.
Scaffold a fresh app in one command:
deno run -Ar jsr:@chevalier/init my-app
cd my-app
deno install
deno task devOpen the printed URL. You get a working app with a page, an island, a static
page, and a /api handler — edit the island and it hot-updates in place.
Or add Chevalier to an existing project:
deno add jsr:@chevalier/coreimport { createApp } from "@chevalier/core";Or run the bundled example to see everything working.
cd examples/basic
deno install
deno task devOpen the printed URL. Edit an island and it hot-updates in place, keeping its
state. Edit a route or _layout.tsx and the page does a full reload.
deno task dev # vite dev server
deno task build # client + SSR build
deno task preview # preview the buildA page is a default-exported Preact component under app/routes/**. It renders
inside the layout and is GET-only. Other methods 404.
// app/routes/about.tsx → GET /about
export default function About() {
return <h1>About</h1>;
}The filename is the URL. index.tsx → /, blog/[slug].tsx → /blog/:slug
(read it with c.req.param("slug")), and docs/[...rest].tsx catches
/docs/a/b/c. _-prefixed files are convention, never routes.
A page also gets its route params as a prop. To fetch data before render,
export const loader — it runs with the Hono context, and whatever object it
returns is merged into the page props. It may be async; render stays sync.
Return a Response instead to short-circuit (redirect, 404, custom status).
// app/routes/blog/[slug].tsx → GET /blog/:slug
import type { PageLoader } from "@chevalier/core";
export const loader: PageLoader = async (c) => {
const post = await getPost(c.req.param("slug"));
if (!post) return c.notFound(); // Response → skips render
return { post };
};
export default function Post({ post }: { post: Post }) {
return <article>{post.title}</article>;
}Any route file can export const app, a Hono sub-app that serves any HTTP
method. Its routes are file-relative. A handler at routes/api.ts declares
.get("/") for GET /api and .post("/echo") for POST /api/echo.
// app/routes/api.ts → mounted at /api
import { Hono } from "hono";
export const app = new Hono()
.get("/", (c) => c.json({ ok: true })) // GET /api
.post("/echo", async (c) => c.json({ echo: await c.req.json() })); // POST /api/echoAn island is a component that hydrates on the client. Make one by putting it
under app/islands/ (reserved at any depth). There is no island() wrapper.
Its default export hydrates.
| Where | Example |
|---|---|
Under app/islands/ |
app/islands/counter.tsx |
// app/islands/counter.tsx (interactive on the client after hydration)
import { useState } from "preact/hooks";
export default function Counter({ start = 0 }: { start?: number }) {
const [n, setN] = useState(start);
return (
<button type="button" onClick={() => setN((v) => v + 1)}>
counts: {n}
</button>
);
}Import it into a page like any component and pass props. Chevalier serializes the props and hydrates the island in the browser. A page that renders no islands emits no client script at all.
_layout.tsx wraps every page. _404.tsx and _error.tsx are opt-in. Import
their default exports and pass them to createApp.
import Layout from "./routes/_layout.tsx";
import NotFound from "./routes/_404.tsx";
import ErrorPage from "./routes/_error.tsx";
createApp({ routes, layout: Layout, notFound: NotFound, error: ErrorPage });notFound renders with status 404 for any unmatched route (and for a page's own
c.notFound()). error renders with status 500 and receives the thrown error
as a prop. Omit either to fall back to Hono's defaults.
Chevalier is inspired by these. Reach for them if they fit better.
- HonoX. The closest shape. File routing and islands on Hono, with its own view layer instead of Preact.
- Fresh. The Deno-native standard. Islands, file routing, and zero client JS by default, rendered with Preact.
Full docs are on the way. For a complete working app in the meantime, see
examples/basic.
