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.