Getting started

Build a multi-app project

Three apps in one project: a public site with no login, a dashboard people log in to, and a JSON API for a mobile client. Both the dashboard and the API read and write the same notes through one shared backend function, and one login works in both. Every command and file below was run as written on a fresh project with the published @devorajs/core and @devorajs/cli (the version it was last run on was 0.3.2, with Node 24, npm and macOS; it has not been re-run for 0.3.5), in devora dev and in a production build.

1. Create the project

npm create devora@latest acme -- --scope=fullstack --apps=site,app --auth=site:none,app:shared --pm=npm
cd acme

That gives two apps: site (auth: "none" — no sessions, no secret needed) and app (auth: "shared", with login and logout routes already there), plus the shared backend in packages/backend. Now add the API as a third, backend-only app on the same shared login:

npx devora add api --kind=api --auth=shared
npm install

apps/api has no pages and no React — only endpoints/ (served under /api): health.ts, session.ts (Bearer login and logout) and me.ts.

2. One function both apps call

Logic more than one app needs goes in packages/backend. A JSON file stands in for the database here, because each app loads backend modules separately — a plain variable would give the dashboard and the API two different lists.

// packages/backend/notes.ts
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { serverFn } from "@devorajs/core";

export interface Note {
  author: string;
  text: string;
}

// A JSON file in the project root stands in for your database. Each app
// loads this module separately (a variable here would NOT be shared between
// apps), but they all read and write the same file. Use a real database
// before deploying: serverless platforms can't write to their filesystem.
const FILE = join(process.cwd(), "notes.json");

function readAll(): Note[] {
  return existsSync(FILE) ? JSON.parse(readFileSync(FILE, "utf-8")) : [];
}

export const listNotes = serverFn(async () => readAll());

export const addNote = serverFn(async (input: { text: string }, ctx) => {
  ctx.requireAuth();
  const author = (ctx.session as { username: string }).username;
  const note = { author, text: input.text };
  writeFileSync(FILE, JSON.stringify([...readAll(), note], null, 2));
  return note;
});

Make it importable as @devorajs/backend/notes by adding it to exports in packages/backend/package.json:

"exports": {
  "./db": "./db/index.ts",
  "./notes": "./notes.ts"
}

3. The dashboard page

A logged-out visitor is sent to /login; an empty note re-renders the form with a 400 and a message; a saved note redirects back (303, so a refresh doesn't post it twice).

// apps/app/routes/notes.tsx
import { CsrfField, PageShell, fail, redirect, type RequestContext, type RouteComponentProps } from "@devorajs/core";
import { addNote, listNotes, type Note } from "@devorajs/backend/notes";

export const renderMode = "ssr";

export function meta() {
  return { title: "Notes" };
}

export async function loader(ctx: RequestContext) {
  if (!ctx.session) throw redirect("/login");
  return { notes: await listNotes(undefined, ctx) };
}

export async function action(formData: FormData, ctx: RequestContext) {
  ctx.verifyCsrf(formData);
  const text = String(formData.get("text") ?? "").trim();
  if (!text) return fail(400, { error: "Write something first." });
  await addNote({ text }, ctx);
  return redirect("/notes");
}

export default function Notes({ data, csrfToken, actionData }: RouteComponentProps<{ notes: Note[] }, { error?: string }>) {
  return (
    <PageShell appName="app">
      <h1>Notes</h1>
      <ul>
        {data?.notes.map((n, i) => (
          <li key={i}>
            <strong>{n.author}</strong>: {n.text}
          </li>
        ))}
      </ul>
      <form method="post">
        <CsrfField token={csrfToken} />
        {actionData?.error ? <p role="alert">{actionData.error}</p> : null}
        <label htmlFor="text">New note</label>
        <input id="text" name="text" />
        <button type="submit">Add</button>
      </form>
    </PageShell>
  );
}

4. The same notes over JSON

// apps/api/endpoints/notes.ts
import { apiRoute, HttpError } from "@devorajs/core";
import { addNote, listNotes } from "@devorajs/backend/notes";

// GET  /api/notes                       -> every note
// POST /api/notes  { "text": "..." }    -> adds one (Authorization: Bearer <token>)
export const methods = ["GET", "POST"];

export const handler = apiRoute(async (req, ctx) => {
  if (req.method === "GET") {
    return json(200, { notes: await listNotes(undefined, ctx) });
  }
  let text: unknown;
  try {
    text = JSON.parse(req.body.toString("utf-8") || "{}").text;
  } catch {
    throw new HttpError(400, "Request body must be valid JSON");
  }
  if (typeof text !== "string" || !text.trim()) throw new HttpError(400, "text required");
  return json(201, { note: await addNote({ text: text.trim() }, ctx) });
});

function json(status: number, body: unknown) {
  return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
}

addNote's requireAuth() turns a request without a valid session into a JSON 401; a wrong method gets a 405 because of methods.

5. Link the public site to the dashboard

In apps/site/routes/index.tsx, replace the heading:

<h1>Acme</h1>
<p className="devora-lead">
  Notes for your team. <a href="http://localhost:10001/notes">Open the app</a>.
</p>

In production that link points at the app's own domain — each app is deployed and served separately, so moving between them is a full page load.

6. Run it

npm run dev

The site is on http://localhost:10000, the dashboard on 10001, the API on 10002, and the boot output lists every route. Open http://localhost:10001/notes: you're sent to the login page (the demo login accepts any name), then can add notes. From another terminal, log in to the API and add a note as a mobile client would:

TOKEN=$(curl -s -X POST -H 'content-type: application/json' \
  -d '{"username":"bob"}' http://localhost:10002/api/session | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"text":"Hello from the API"}' http://localhost:10002/api/notes
# {"note":{"author":"bob","text":"Hello from the API"}}

curl -s http://localhost:10002/api/notes
# {"notes":[{"author":"alice","text":"Hello from the app"},{"author":"bob","text":"Hello from the API"}]}

Reload the dashboard and bob's note is there. One login: the token the API issued is a session in the same shared scope, so the dashboard accepts it too — curl -H "Authorization: Bearer $TOKEN" http://localhost:10001/notes returns the page (200) instead of a redirect to /login. A browser that logged in on the dashboard and a phone holding a token are two sessions of the same kind; revoking either (DELETE /api/session, or /logout) ends it in every app.

Browsers and several domains

In dev every app is on localhost, so the browser sends one session cookie to all of them. On separate subdomains in production, set shared.sessions.cookie.domain to your domain so the cookie reaches every app (see Security model). Bearer tokens aren't affected.

7. Try the production build locally

A production server refuses to start without a session store. For one local process, replace the commented // sessions: { store: ... } line in devora.config.ts with:

    sessions: { store: "memory" },

then build and start all three apps:

npm run build
DEVORA_SESSION_SECRET=$(openssl rand -base64 32) npm start

npm start serves the apps on 4173, 4174 and 4175, in devora.config.ts order. The same steps work there: login on 4174/login, the token from 4175/api/session accepted by the dashboard.

8. Before deploying

  • Replace notes.json with your database, and "memory" with a session store backed by it (Security model → Sessions). Serverless functions can't write files, and each instance would have its own memory.
  • Each app is its own Vercel project or Netlify site, with that app's folder as the root; the committed vercel.json/netlify.toml already build it. See Deployment.
  • site needs no secret. app and api both need the same DEVORA_SESSION_SECRET, since they share one session scope.
  • Point the site's link at the dashboard's real domain, and set the real domains in devora.config.ts.

Next: Pages, forms and errors for everything a route can do, and Backend for API routes, CORS and middleware.