Pages
A 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.
Loading data
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).
Type the loader with satisfies PageLoader, then type the page with PageProps<typeof loader> — the payload flows through, so the page can't drift from what the loader returns:
// app/routes/blog/[slug].tsx → GET /blog/:slug
import type { PageLoader, PageProps } from "chevalier";
export const loader = (async (c) => {
const post = await getPost(c.req.param("slug"));
if (!post) return c.notFound(); // Response → skips render
return { post };
}) satisfies PageLoader;
export default function Post({ post, params }: PageProps<typeof loader>) {
return <article>{post.title}</article>;
}Forms
To handle a form, export const action alongside loader: loader reads on GET, action writes on POST. A <form method="post"> posts to its own page, so both live in one file. Return a 303 redirect (normally back to the same path) — the browser re-GETs and loader re-runs with the new state.
// app/routes/guestbook.tsx → GET renders, POST signs
import type { PageAction, PageLoader, PageProps } from "chevalier";
export const loader = (() => ({ entries: readEntries() })) satisfies PageLoader;
export const action: PageAction = async (c) => {
const message = (await c.req.formData()).get("message")?.toString();
if (message) addEntry(message);
return c.redirect(c.req.path, 303); // PRG: re-GET runs the loader again
};
export default function Guestbook({ entries }: PageProps<typeof loader>) {
return (
<form method="post">
<input name="message" />
<button type="submit">Sign</button>
</form>
);
}Actions are CSRF-protected out of the box: same-origin <form> posts just work, while a cross-origin post from a browser is rejected with a 403. A post larger than 1 MiB is rejected with a 413 — plenty for forms; for file uploads, use a handler and set your own limit.
Use action for a form that belongs to a page. For a standalone endpoint with no page, use a handler.