Building apps

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 here

The 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.yaml

hono+static

One index.html, no frontend build, Hono backend function.

frontend/index.html
server/index.ts

tanstack

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) or tanstack will 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 needWhat to use
Company metrics from your databaseA saved query via query()
Someone's own Gmail, Slack, or similarA connector they link and own
A shared team Stripe or CRM accountAn org-managed connector
Settings, drafts, approvals, recordsdb, one flat store you partition
Upload, store, download filesfiles
Summarize or classify while the user waitsllm in the backend function
Read, extract, or generate a file with AIA managed agent
Run on a scheduleA scheduled route, or the agent's own schedule
Send transactional emailemail.send()
Call an arbitrary APIDeclare the host under egress:, or use a connector

On this page