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:

FieldSource
reqThe raw BunRequest
request_urlpathname + search of the incoming URL
prefixThe URL prefix the route is mounted under (e.g. admin) or null
langActive language from X-Lang header → lang cookie → default
localeLocale string for the active language (e.g. en-US)
preferred_langThe user's explicit cookie language preference, if any
userThe current user row from resolve_session() - null if not signed in
translationsRoute namespace + root merged for lang; read in templates via {_ } / {- } / {@ }
toastsToast 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:

OptionTypeDescription
dataobjectTemplate-specific data merged on top of the global base data
ctxRequestContextOutput of create_ctx(req, import.meta.dir) - populates user, lang, locale, prefix, translations, toasts, request_url
statusnumberHTTP status code, defaults to 200
headersobjectExtra 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.