The app model
A Railcode app is a static frontend plus a backend function. What that split gives you, the four templates, and the files in a project.
A Railcode app is a static frontend plus a backend function, deployed and versioned as one unit. It is served behind the Railcode proxy, so everyone who reaches the frontend has already been through company auth.
you ──▶ Railcode proxy ──▶ frontend ──fetch('/api/…')──▶ your backend ──@railcode/sdk──▶ platform
company auth │ │
no credentials ctx.user (verified, unforgeable)
no authority all authority lives hereThe split, and why it matters
The frontend is static files. It holds nothing and proves nothing. Any check it makes is a
UX convenience rather than a control. A user can call /api/* directly with curl from a
logged-in session, so every rule must also exist in the backend function.
There is no browser SDK. The only platform endpoints a page may call are /_api/me and
/_api/logout (the chrome bar uses them). Even then, it is usually better to get identity from
your own backend route, so there is one source of truth.
The backend function is where everything real happens. It imports @railcode/sdk, it is
the app's principal, and it receives a verified ctx.user. That is the point of the split:
"X submits, Y approves, X can't approve their own" now lives in a trusted place instead of in
a browser tab.
If you find yourself reaching for a window.db or a /_api data call from the page, stop.
That is the legacy shape.
Templates
We offer Hono + Vite and TanStack templates out of the box. You can also use any stack or JavaScript framework you like, as long as it builds into a static frontend and a single ES module backend function.
railcode init creates one of four templates. The CLI owns the build for all of them. The app
declares no bundler and no backend build script.
hono+vite (default)
Vite + React frontend, Hono backend function.
frontend/ # client (Vite root)
server/index.ts # export default app
railcode.json # { app, type, dist, server }
manifest.yamlhono+static
One index.html, no frontend build, Hono backend function.
frontend/index.html
server/index.tstanstack
TanStack Start in SPA mode. Server functions (/_serverFn/*) and routes (/api/*) run in
the backend function. Data routes must be ssr: false.
static
Pure hosting. No backend function, and no backend manifest keys.
Bring your own
Any bundler that emits a static dist plus one self-contained ES module with
export default { fetch }. Point "dist" and "server" at them.
The backend uses the same format as Cloudflare Workers, so most stacks can already target it:
export default {
async fetch(request) {
return new Response("hello");
},
};One rule: the module must inline everything (esbuild bundle: true, Vite
inlineDynamicImports). A code-split or CJS backend module deploys and then crashes on the
first request.
A note on Next.js. You can deploy a Next.js app by building it with OpenNext and targeting this format, but we don't recommend it. Next.js is tightly coupled to Vercel's features and generally does not work well outside Vercel. If you like React,
hono+vite(React + Vite on the front, Hono on the back) ortanstackwill be much easier.
Project files
railcode.json describes what the CLI builds and deploys:
{
"app": "my-app",
"type": "hono+vite",
"dist": "dist/client",
"server": "dist/server/index.js"
}type drives build, dev, and deploy. server names the built backend module; dist the
static output.
manifest.yaml declares what the app is allowed to do. See concepts and the
backend SDK reference.
.railcode is a local marker recording which deploy this folder matches. Never commit it;
railcode init adds it to .gitignore for you.
Frontend patterns
One small client module, and nothing else talks to the network.
// src/lib/api.ts
async function call(path: string, init?: RequestInit) {
const res = await fetch(path, {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw Object.assign(new Error(await res.text()), { status: res.status });
return res.status === 204 ? null : res.json();
}
export const api = {
me: () => call("/api/me"),
notes: () => call("/api/notes"),
saveNote: (n: unknown) => call("/api/notes", { method: "POST", body: JSON.stringify(n) }),
};Components and stores import api. They never call fetch directly, never build URLs, and
never see a platform endpoint.
Give every top-level section its own path, like /companies and /companies/acme. Never
keep navigation in an in-memory view variable. Deep links, hard refresh, and back/forward
must work, because people paste these apps into Slack and tickets. Serving falls back to
index.html, so client-side routes resolve with no config.
Show real empty and error states. An unconfigured connector, an unlinked account, or an empty collection should show something accurate that the user can act on, not a spinner forever.
Give every app a favicon. People pin these tools in a row of tabs, where a blank icon is
hard to find. Draw a small SVG that says what the app is, keep it readable at 16px, and set a
real <title> beside it.
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />Choosing a feature
| What you need | What to use |
|---|---|
| Company metrics from your database | A saved query via query() |
| Someone's own Gmail, Slack, or similar | A connector they link and own |
| A shared team Stripe or CRM account | An org-managed connector |
| Settings, drafts, approvals, records | db, one flat store you partition |
| Upload, store, download files | files |
| Summarize or classify while the user waits | llm in the backend function |
| Read, extract, or generate a file with AI | A managed agent |
| Run on a schedule | A scheduled route, or the agent's own schedule |
| Send transactional email | email.send() |
| Call an arbitrary API | Declare the host under egress:, or use a connector |