Building apps

Writing the backend function

Route shape, relaying platform errors, and why authorization is code you write against the verified caller.

The backend function handles everything under /api/* (and /_serverFn/* on TanStack). The platform routes those paths to your backend function. Everything else is served as a static file.

The shape

import { Hono } from "hono";
import { ApiError, ctx, db } from "@railcode/sdk";

const app = new Hono();

app.get("/api/me", (c) => c.json({ user: ctx.user }));

app.get("/api/notes", async (c) => {
  const rows = await db.collection("notes").query()
    .where("owner", "=", ctx.user!.id)
    .order("updated_at", "desc")
    .page(1, 100);
  return c.json({ items: rows });
});

export default app;

Authorization is your code

There is no scoped store that enforces ownership. db is one flat store. A key prefix is a convention, and the check is what makes it real.

// ❌ the id came from the caller, so reading it proves nothing
const row = await db.collection("notes").get(c.req.param("id"));
return c.json(row);

// ✅ verify what you read against the verified caller
const row = await db.collection("notes").get(c.req.param("id"));
if (!row || row.owner !== ctx.user!.id) return c.json({ error: "not found" }, 404);

Prefer 404 over 403 for records the caller should not know exist. A 403 confirms that the record exists.

Never trust a value from the request body for identity or ownership. Read it from ctx.user. The one thing a backend function gets for free is a caller it can trust.

A rule like "X submits, Y approves, X can't approve their own" is backend code, in a place a user cannot reach:

app.post("/api/requests/:id/approve", async (c) => {
  const user = ctx.user;
  if (!user) return c.json({ error: "cron cannot approve" }, 409);

  const req = await db.collection("requests").get(c.req.param("id"));
  if (!req) return c.json({ error: "not found" }, 404);
  if (req.submitted_by === user.id) return c.json({ error: "cannot approve your own" }, 403);
  if (!user.is_admin) return c.json({ error: "approver role required" }, 403);

  await db.collection("requests").put(req.id, {
    ...req, status: "approved", approved_by: user.id,
  });
  return c.json({ ok: true });
});

Relay platform errors unchanged

When a route wraps an SDK call, catch ApiError and respond with its .status and body. The frontend's 403/409/429 handling depends on the status surviving the extra hop. Collapsing it into a 500 throws away every typed error the platform gives you.

async function relay<T>(c: Context, fn: () => Promise<T>) {
  try {
    return c.json(await fn());
  } catch (err) {
    if (err instanceof ApiError) {
      let body: unknown;
      try { body = JSON.parse(err.message); } catch { body = { detail: err.message }; }
      return c.json(body, err.status);
    }
    throw err;
  }
}

app.post("/api/charge", (c) =>
  relay(c, () => connector("stripe").fetch("/v1/charges", { method: "POST" })));

Then the frontend can act on meaning:

if (res.status === 409) showConnectPrompt();   // connector not linked / needs re-auth
if (res.status === 429) showQuotaNotice();     // daily cap
if (res.status === 403) showNotAllowed();      // undeclared authority

Statuses to handle by name:

StatusMeans
403Undeclared authority. The manifest does not name this resource
409Not connected, or the operation is incompatible with a cron trigger
429A daily cap (typed quota)

Handling a null caller

ctx.user is null on cron triggers. A route that reads ctx.user.roles or filters by ctx.user.id will throw under cron. Or worse, it will return everything, because the flat store enforces nothing.

if (!ctx.user) return c.json({ error: "cron cannot do this" }, 409);

See scheduled work for the full trigger model.

Common pitfalls

PitfallWhat happensFix
Checking permissions only in the frontendAnyone can curl /api/* from a logged-in sessionCheck again in the backend function against ctx.user
Taking a user id from the request bodyEasy to spoofRead ctx.user.id
Assuming a key prefix isolates dataIt does not; db is flatVerify the owner on read
Using query() without pagingSilently drops everything past the first pageLoop until a short page
A loop of files.url()Uses up the subrequest budgetfiles.urls(names)
A GET cron route404s on every fireAccept POST
Swallowing ApiError into a 500The UI cannot tell quota from forbiddenRelay .status unchanged
A hand-rolled ReadableStreamA mid-stream failure vanishes; a hang-up keeps burning tokenstoNdjson(source)
A code-split or CJS backend moduleDeploys, then crashes on the first requestOne self-contained ES module

On this page