Core concepts
Multi-app architecture
A devora.js project can declare more than one app — a marketing site, a dashboard, an admin panel — as siblings under apps/, all registered in one devora.config.ts at the project root. This isn't a monorepo tool bolted on after the fact; it's the framework's headline feature. Each app has its own routes/ directory and its own app.config.ts, but they share one packages/core for common logic and, by default, one packages/backend for server functions and the DB client. Each app can be built and deployed independently (devora build --app=admin) or all together (devora build), and each is served from its own domain.
What Vite does
Devora.js currently uses Vite for the dev server (one per app, which also loads and reloads your server modules) and for the client build. Routing, the render modes, sessions, the shared backend and the production server (devora start, which runs without a Vite dev server) are Devora.js's own code.
Auth: shared, isolated, or none
Auth is a per-app setting, not a single project-wide switch. Three modes:
shared the default. All apps set to shared are one session scope: one secret (DEVORA_SESSION_SECRET) and one server-side session store, so a session created by any of them is accepted by all of them — marketing, dashboard, and admin alike. The client holds only an opaque session ID, and it works the same over either transport: an HttpOnly cookie for browsers, or Authorization: Bearer <id> for mobile and API clients. Revoking it deletes the record from the store, so it stops working everywhere at once.
One browser detail: by default the cookie is host-only, so browsers only send it back to the host that set it. Apps on the same host — including every app in devora dev, all on localhost — share a browser login. For apps on separate subdomains in production, set shared.sessions.cookie.domain: "example.com" in devora.config.ts; every shared app must be served from that domain or a subdomain of it (checked at build and dev start). Bearer clients aren't affected.
isolated this app is its own session scope: its own cookie name and, optionally, its own secret (DEVORA_SESSION_SECRET_<APPNAME>). A session issued by a shared app isn't found here — as a cookie or as a Bearer token — even if the two share a secret and a store. Useful when one app genuinely needs a separate identity provider or session boundary — an admin panel is the typical case.
Both modes keep session records in the store configured at shared.sessions.store in devora.config.ts. In dev an in-memory store is used if it's unset; in production it's required — see Security model → Sessions and Deployment.
none disables sessions entirely for that app — no session cookie, no Bearer lookup, no CSRF, and no session secret or store required. This isn't the same as inheriting the project default; it's a real third state, set explicitly. It exists because an app with no login route anywhere (a marketing site) shouldn't need to configure a session secret it will never use. Calling a session method (setSession(), requireAuth(), etc., on the request context) inside a none app throws a clear error rather than silently doing nothing — and the build step checks for this upfront, so a misconfigured route fails at devora build time, not when a real request hits it.
Leaving auth unset on an app means "inherit the project's shared.auth default" — set it to "none" explicitly if that's genuinely what you want, so it's unambiguous from the config file alone which apps have sessions enabled at all.
The shared backend pattern
By default, every app calls into one shared backend (packages/backend) for server functions and the DB layer — a single source of truth for business logic, callable directly from any app's routes with no hand-written fetch + API route boilerplate:
// packages/backend/functions/settings.ts
export const updateSettings = serverFn(async (input, ctx) => {
// ctx exposes requireAuth() to gate this on a valid session
return db.settings.update(input);
});
// apps/dashboard/routes/settings.tsx
import { updateSettings } from "@devorajs/backend/settings";
// call it directly — same function, same DB, same logic every app usesAn app can still define a function locally, inside its own routes/, when something is genuinely app-specific — an admin-only bulk-import function nobody else needs, for example. This is an opt-out per function, not a project-wide switch: most projects use the shared backend for almost everything and only reach for an app-local function occasionally.
File-based routing
Each app routes itself from its own routes/ directory — a file's path under routes/ becomes its URL path, and routes/index.tsx is that app's home page. A route file exports whatever it needs explicitly: loader for server-side data, a default-exported component for the UI, and an optional action for mutations (form posts). Nothing is inferred beyond the path itself — there's no special meaning attached to a filename beyond where it sits in the tree.
| File | URL |
|---|---|
routes/index.tsx | / |
routes/about.tsx | /about |
routes/blog/index.tsx | /blog |
routes/blog/[slug].tsx | /blog/hello-world, etc. |
routes/settings/profile.tsx | /settings/profile |
A folder nests exactly like this — as deep as you want, no config. index.tsx at any depth is that folder's own path (drops the index segment); every other file adds its own name as a segment.
A dynamic segment — routes/users/[id].tsx matches /users/123, with the value available as ctx.params.id in loader/action — is supported for ssr and csr routes. A static route at the same depth always wins over a dynamic one (routes/users/new.tsx beats routes/users/[id].tsx for /users/new). ssr/csr support dynamic routes with no extra work — a live request already carries its own params. A [param] matches exactly one path segment.
A catch-all — routes/docs/[...slug].tsx — matches one or more segments: /docs/a, /docs/a/b/c (not /docs; use [[...slug]] for that). ctx.params.slug is the segments joined with / ("a/b/c", still a string, so code written for [id] compiles unchanged) and ctx.catchAll.slug is the array (["a", "b", "c"]). Precedence is static, then [param], then [...slug]; a catch-all must be the last segment, and two files that match the same URLs are an error naming both. Segments are decoded once; an empty segment, ., .. or an encoded slash never matches. It works for page routes and API routes, and for ssg/isr through getStaticParams() returning { slug: string[] } entries (since 0.3.4).
Next to routes/, an app can have an error.tsx (its 404 and error page) and an API folder — endpoints/ in new apps, set by apiDir in app.config.ts — whose files are served under /api by the same router. See Pages, forms and errors and Backend.
ssg/isr on a dynamic route need a getStaticParams() export telling the build step which concrete values to pre-render — one static file per entry returned. See Backend for the full shape.