Controllers
Introduction
In Reepolee, a controller is a plain async function. There is no class to extend, no base controller to inherit from. Each route handler lives in routes/<feature>/index.ts alongside the templates, queries, and translations it works with.
Every handler receives a BunRequest and must return a Response. Everything else - what data you load, how you validate it, what you render - is up to you.
export async function get_users_index(req: BunRequest): Promise<Response> {
const ctx = await create_ctx(req, import.meta.dir);
const records = await get_all_records();
return render("users/index", {
data: { records },
ctx,
});
}
That's the whole pattern. Build the request context (translations included), load the data, call render(). Reading the request body, building responses, and handling errors each have their own pages - see Requests & Responses and Error Handling.
The Request Context
create_ctx(req, import.meta.dir) from $lib/request_context returns a RequestContext populated with everything the rest of the request needs in one place. Pass import.meta.dir so it can derive the route's translation namespace:
| Field | Source |
|---|---|
req | The raw BunRequest |
request_url | pathname + search of the incoming URL |
prefix | The URL prefix the route is mounted under (e.g. admin) or null |
lang | Active language from X-Lang header → lang cookie → default |
locale | Locale string for the active language (e.g. en-US) |
preferred_lang | The user's explicit cookie language preference, if any |
user | The current user row from resolve_session() - null if not signed in |
translations | Route namespace + root merged for lang; read in templates via {_ } / {- } / {@ } |
toasts | Toast notifications read from the request's toast cookies by get_toast_cookies() |
Resolving the session inside create_ctx means every handler that builds a ctx gets the current user for free with a single database hit. The layout, navbar, and any template that reads props.user work without each handler having to wire the session up. Translations are resolved the same way - once, here - and exposed to templates as props.translations.
Rendering a Response
The render() helper from $lib/render compiles a .ree template and returns an HTML Response. Pass the template path and an options object with your data and the incoming request:
import { render } from "$lib/render";
import { create_ctx } from "$lib/request_context";
export async function get_users_edit(req: BunRequest): Promise<Response> {
const ctx = await create_ctx(req, import.meta.dir);
const id = req.params.id;
const record = await get_record_by_id(id);
return render("users/form", {
data: {
title: `Edit ${record.email}`,
record,
action: `/users/${record.id}/edit`,
},
ctx,
});
}
ctx is what populates the current user, active language, locale, prefix, translations, and any pending toast notifications in the template. The full options object:
| Option | Type | Description |
|---|---|---|
data | object | Template-specific data merged on top of the global base data |
ctx | RequestContext | Output of create_ctx(req, import.meta.dir) - populates user, lang, locale, prefix, translations, toasts, request_url |
status | number | HTTP status code, defaults to 200 |
headers | object | Extra response headers |
The full set of values that render() injects into every template - user, lang, locale, toasts, request_url, and so on - is covered in Helpers & Globals.
Translations
You don't load translations separately - create_ctx(req, import.meta.dir) already resolves them onto ctx.translations (route namespace merged over the global root), and render() exposes them to the template as props.translations. Templates read strings with the {_ } / {- } / {@ } lookup tags ({@ } renders the value through markdown):
export async function get_users_index(req: BunRequest): Promise<Response> {
const ctx = await create_ctx(req, import.meta.dir);
const records = await get_all_records();
return render("users/index", {
data: { records },
ctx,
});
}
<h1>{_ ui.title }</h1>
{#each props.records as record }
<span>{_ labels.email }: {= record.email }</span>
{/each}
When a handler needs a string for its own logic - a validation message, a toast - it reads ctx.translations directly (validate(data, ctx.translations.errors)). No key is spread through data. The full localisation flow - adding a language, layering route-level and global keys, generating localised URLs - is covered in Translations.
The _action Convention
HTML forms only support GET and POST. For destructive operations like delete, Reepolee uses a _action hidden field to communicate intent through the same POST handler that processes updates:
<form id="delete-form" method="POST" action="/users/{= props.record.id }/edit">
<input type="hidden" name="_action" value="delete" />
</form>
The POST handler checks _action first and branches accordingly:
export async function post_users_edit(req: BunRequest): Promise<Response> {
const params = new URLSearchParams(await req.text());
const action = params.get("_action");
if (action === "delete") {
await delete_record(req.params.id);
return Response.redirect("/users", 303);
}
// otherwise treat as an update
// ...
}
This keeps the route table small - one URL per resource action rather than separate /edit, /delete, /restore endpoints - and matches the pattern used throughout the generated CRUD code.
Confirming Feedback to the User
After a successful create, update, or delete, redirect with a toast notification so the next page load surfaces the result. Toasts are sent as cookies on the redirect response and consumed by the <toasts-area> web component in your layout - the controller does not have to know anything about how they're displayed.