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

FileMaps 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.tsxWraps pages in its directory (nests)
routes/_middleware.tsRuns before routes (nests)
routes/_404.tsx · _500.tsxNot-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.