Routing & data
File-based routes with nested layouts, middleware, server loaders, form actions, and API routes. If you've used Next.js or Remix, this will feel familiar.
File conventions
| File | Maps to |
|---|---|
routes/index.tsx | / |
routes/about.tsx | /about |
routes/blog/[slug].tsx | /blog/:slug (dynamic param) |
routes/api/posts.ts | /api/posts (handler, not a page) |
routes/_layout.tsx | Wraps pages in its directory (nests) |
routes/_middleware.ts | Runs before routes (nests) |
routes/_404.tsx · _500.tsx | Not-found / error pages |
Static segments win over dynamic ones at the same depth, so /users/me beats /users/[id].
Loaders run on the server
A route can export a loader. It runs only on the server (so secrets and DB access stay there) and its result is passed to the page as data.
// routes/blog/[slug].tsx
export const loader = async ({ params }) => ({
post: await db.post(params.slug), // server-only
});
export default function Post({ data }) {
return <article><h1>{data.post.title}</h1></article>;
}Actions handle mutations
A non-GET request runs the route's action. Return a Response,ctx.redirect() (Post/Redirect/Get), or data to re-render the page with.
// routes/guestbook.tsx
export async function action(ctx) {
const form = await ctx.formData();
ctx.setCookie("guestbook", add(form.get("msg")));
return ctx.redirect("/guestbook"); // PRG
}Layouts & middleware nest
_layout.tsx wraps every page under its directory and composes from root to leaf._middleware.ts runs before the route (root → leaf); return a Response to short-circuit — useful for auth, redirects, and response headers.
// routes/_middleware.ts
export default function (ctx) {
if (ctx.url.pathname.startsWith("/admin") && !ctx.cookies.session) {
return ctx.redirect("/login");
}
ctx.setHeader("x-frame-options", "DENY");
}API routes
Files under routes/api/ are handlers. Export per-method functions (GET, POST, …) or a default; return aResponse (streaming supported) or a value to JSON-encode. Unmatched methods get a 405.
// routes/api/echo.ts
export const GET = (ctx) => ctx.json({ q: ctx.url.searchParams.get("q") });
export const POST = async (ctx) => ctx.json({ received: await ctx.bodyJson() });Streaming SSR
Opt a route into streaming with export const streaming = true; the shell is sent first and the body streams in, lowering time-to-first-byte for slow pages.