Build your first feature
Getting started scaffolds a project; the API reference lists every function. Neither shows how they fit together on a real request. This page builds one small, complete feature — a guestbook — touching every piece: a loader, a serverFn, a CSRF-protected form action, and requireAuth(). Assumes a project already scaffolded with shared or isolated auth (see Getting started) so a session exists to protect the form with.
1. The shared function
Following the ownership rule: logic other apps might reuse goes in packages/backend, not inside the route file itself.
// packages/backend/functions/guestbook.ts
import { serverFn } from "@devorajs/core";
const messages: { author: string; text: string }[] = [];
export const listMessages = serverFn(async () => {
return messages;
});
export const addMessage = serverFn(async (input: { author: string; text: string }, ctx) => {
ctx.requireAuth(); // only a logged-in visitor can post
messages.push(input);
return { ok: true };
});Then make it importable: packages/backend/package.json only exports ./db in a fresh project, so add the new file to its exports:
"exports": {
"./db": "./db/index.ts",
"./guestbook": "./functions/guestbook.ts"
}A real app swaps the in-memory array for a DB call (Prisma, Drizzle, whichever) — the shape of serverFn itself doesn't change either way; see The shared backend pattern. The array lives in one app's server; another app importing the same file gets its own copy, which is one more reason to use a database.
2. The route: loader + form
A route's loader runs server-side before the page renders — this is how listMessages gets called. The form below embeds the request's csrfToken, passed automatically as a prop on every GET render.
// apps/dashboard/routes/guestbook.tsx
import { CsrfField } from "@devorajs/core";
import { listMessages } from "@devorajs/backend/guestbook";
export const renderMode = "ssr"; // fresh data every request — see /render-modes
export async function loader() {
return { messages: await listMessages() };
}
export default function Guestbook({ data, csrfToken }) {
return (
<>
<h1>Guestbook</h1>
<ul>
{data.messages.map((m, i) => (
<li key={i}><strong>{m.author}</strong>: {m.text}</li>
))}
</ul>
<form method="post">
<CsrfField token={csrfToken} />
<label>Name <input name="author" required /></label>
<label>Message <textarea name="text" required /></label>
<button type="submit">Post</button>
</form>
</>
);
}3. The action: verify, call, redirect
A form POST to the same route runs its action export instead of loader. Three steps, in order: check the CSRF token actually matches this browser's cookie, call the shared function (which itself checks requireAuth()), then redirect — so a page refresh after submitting doesn't re-post the form.
// same file, apps/dashboard/routes/guestbook.tsx
import { redirect } from "@devorajs/core";
import { addMessage } from "@devorajs/backend/guestbook";
export async function action(formData, ctx) {
ctx.verifyCsrf(formData);
await addMessage({
author: String(formData.get("author")),
text: String(formData.get("text")),
}, ctx);
return redirect("/guestbook");
}Where each check actually lives
ctx.verifyCsrf(formData) in the action confirms this POST came from a real form this app rendered, not a forged cross-site request. ctx.requireAuth() inside addMessage confirms the visitor is actually logged in. They check different things and both matter — CSRF alone doesn't prove who's asking, and an auth check alone doesn't prove the request wasn't forged from another origin using the visitor's own valid cookies.
4. Run it
npm run dev, then log in (a shared/isolated-auth scaffold already generated a login.tsx route) and visit /guestbook. Submitting without logging in first throws from requireAuth() — a 401, rendered by the app's error.tsx; submitting with a stale or missing CSRF field throws from verifyCsrf — a 403. Both are real errors, not silent no-ops — see Security model for why. To show a validation message on the form instead of an error page, return fail(400, { error }) from the action and read actionData in the component (see Pages, forms and errors).
From here: swap the array for a real DB client, move validation into addMessage itself, or expose the same data over apiRoute() for a mobile client — see Backend and the API reference.