> ## Documentation Index
> Fetch the complete documentation index at: https://better-router.bansal.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Loaders and actions

> Co-located .server.tsx files load page data and handle form mutations.

Each page can have a companion `.server.tsx` file. That module never ships to the browser.

```
src/routes/projects/[id].tsx
src/routes/projects/[id].server.tsx
```

## Loaders

`loader` runs on `GET`. Its return value becomes the page component's props and `useLoaderData()`.

```ts title="src/routes/projects/[id].server.tsx" theme={null}
import type { LoaderArgs } from "better-router";
import { getEnv, redirect } from "better-router";

export async function loader({ params, c }: LoaderArgs) {
  const id = params.id!;
  const db = getEnv().DB as D1Database;
  const row = await db.prepare("select id, name from projects where id = ?").bind(id).first();
  if (!row) return redirect("/projects");
  const tab = c.req.query("tab") ?? "overview";
  return { project: row, tab };
}
```

```tsx title="src/routes/projects/[id].tsx" theme={null}
import { useLoaderData } from "better-router/react";

export default function ProjectPage() {
  const { project } = useLoaderData<{ project: { name: string } }>();
  return <h1>{project.name}</h1>;
}
```

`LoaderArgs`:

| Field     | What it is                                                 |
| --------- | ---------------------------------------------------------- |
| `c`       | Hono context. Cookies, headers, `c.env`, `c.set` / `c.get` |
| `params`  | Dynamic segments (`{ id: "abc" }`)                         |
| `request` | The raw `Request`                                          |

`redirect("/login")` from a loader sends the browser there. Prefer that for HTML pages. Throw `HTTPException(401)` from APIs — see [Errors](/errors).

## Actions

`action` handles `POST`, `PUT`, `PATCH`, and `DELETE` for the same URL. Named actions live on `actions` and are selected with `?action=` or `?_action=`.

```ts title="src/routes/projects/[id].server.tsx" theme={null}
import type { ActionArgs } from "better-router";
import { getEnv, redirect } from "better-router";

export async function action({ params, formData }: ActionArgs) {
  const name = String(formData?.get("name") ?? "");
  const db = getEnv().DB as D1Database;
  await db.prepare("update projects set name = ? where id = ?").bind(name, params.id!).run();
  return redirect(`/projects/${params.id}`);
}

export const actions = {
  archive: async ({ params }: ActionArgs) => {
    const db = getEnv().DB as D1Database;
    await db.prepare("update projects set archived = 1 where id = ?").bind(params.id!).run();
    return redirect("/projects");
  },
};
```

After an action:

| Return                  | What the browser does                                                                   |
| ----------------------- | --------------------------------------------------------------------------------------- |
| `redirect("/path")`     | SPA navigate (or `303` document redirect on island pages)                               |
| object                  | Re-renders the page with that object as loader data (SPA JSON payload or HTML document) |
| `undefined` / no return | Re-runs the loader and re-renders                                                       |
| `Response`              | used as-is                                                                              |

Use `redirect(url, 303)` after POST so the browser does not replay the form. `redirect(url)` is `302`.

## Forms

```tsx theme={null}
import { Form } from "better-router/react";

export default function EditProject() {
  return (
    <Form method="post">
      <input name="name" />
      <button type="submit">Save</button>
      <button type="submit" formAction="?action=archive">
        Archive
      </button>
    </Form>
  );
}
```

`<Form>` intercepts submit, posts `multipart`/`urlencoded` as the browser would, and follows redirects through the SPA router. Add `data-enhance="false"` on a submitter to do a full document post.

`useForm()` is a small controlled helper (`data`, `errors`, `pending`, `post(url)`). It treats HTTP `422` as field errors. Island pages should use `useIslandForm()` — same API, but a successful action reloads the document.

### File uploads

```tsx theme={null}
<Form method="post" encType="multipart/form-data">
  <input type="file" name="avatar" accept="image/*" />
  <button type="submit">Upload</button>
</Form>
```

```ts theme={null}
import type { ActionArgs } from "better-router";
import { getEnv, redirect } from "better-router";

export async function action({ formData }: ActionArgs) {
  const file = formData?.get("avatar");
  if (!(file instanceof File) || file.size === 0) return redirect("/settings");
  const key = `avatars/${crypto.randomUUID()}`;
  const bucket = getEnv().BUCKET as R2Bucket;
  await bucket.put(key, await file.arrayBuffer(), {
    httpMetadata: { contentType: file.type || "application/octet-stream" },
  });
  return redirect("/settings");
}
```

See [Bindings](/bindings) for R2.

## Cookies and headers

There is no wrapper around cookies. Use Hono's helpers on `c`:

```ts theme={null}
import type { ActionArgs } from "better-router";
import { setCookie, getCookie } from "hono/cookie";

export async function loader({ c }: ActionArgs) {
  return { locale: getCookie(c, "locale") ?? "en" };
}

export async function action({ c, formData }: ActionArgs) {
  setCookie(c, "locale", String(formData?.get("locale") ?? "en"), {
    path: "/",
    httpOnly: true,
    sameSite: "Lax",
  });
  return { ok: true };
}
```

## Render flags

Export these from the `.server.tsx` file:

```ts theme={null}
export const ssr = false;      // send an empty #app, render on the client
export const spa = false;      // always do a full document navigation
export const hydrate = false;  // static HTML, skip client runtime
```

`prerender` is reserved and not implemented. Do not export it expecting a static snapshot.

## Request-scoped helpers

Inside a loader, action, or middleware you can call:

```ts theme={null}
import { getEnv, getHonoContext } from "better-router";
```

They use `AsyncLocalStorage`. They throw from a `scheduled` / `queue` / `email` handler — pass `env` from `defineWorker` instead. See [Worker entrypoint](/worker).
