Build an app

Create a working application on Xeplr OS with @xeplr/cli, including sign-in, a guarded API, a React UI and a sample built with the screen designer.

npx @xeplr/cli@latest new myapp

@latest makes npx fetch the newest version instead of one it cached earlier. The command asks a few questions, writes the project, installs it and sets up its databases. The CLI has two commands, xeplr new and xeplr encrypt. See The CLI.

Before you start

Requirement Why
Node.js 18 or later the CLI itself (engines). @xeplr/factory, which the generated API uses, states Node 22.12 or later
PostgreSQL, running with a user allowed to create databases
Redis, running sign-in sessions are kept there
brew services start redis            # macOS
sudo systemctl start redis           # Linux
docker run -d -p 6379:6379 redis     # Windows: Redis has no native build, so use Docker or WSL

Without Redis, login appears to work and then every request is refused as “Invalid or expired token”. The sign-in service checks for Redis at startup and refuses to start rather than let you run into that message.

It works on macOS, Linux and Windows, and nothing it generates depends on a Unix shell.

Questions it asks

Question Decides
Project name the folder, and the database prefix
Port decade three consecutive ports: ui, sign-in, api
Super admin email the account you sign in with
Super admin password its password
Multi-tenant? no, or the levels, outermost first: company, or company, workspace
Database connection host, port, user and password, or skip and fill it in later

Encryption keys and signing secrets are generated, never asked for.

Use a new project name for a new app. The name sets the database names (<name>_auth, <name>_api). An existing database is kept as it is, with its accounts, so the super admin you enter is created only in a fresh one.

If you skip the connection question

The setting is left blank, with npx @xeplr/cli encrypt written directly above it in api/development.env. Host, port, user and password are encrypted together into one string. A guessed value would start cleanly and read as empty data, so until you fill it in the app refuses to start and names the setting.

What you get

myapp/
├─ api/        the API and the sign-in service
├─ ui/         the React app
└─ CLAUDE.md   how to change this app, written for Claude and readable by people
  • Sign-in. Login, register, forgot and reset password, activation, profile, change password, and the admin screens for users, roles and permissions (@xeplr/auth, @xeplr/ui-account).
  • A guarded API. Every address needs a valid token. An address you want open has to be named explicitly.
  • Tasks, a sample built with the screen designer. It is a list whose New and Edit open the task form in a popup and save as you type into an ordinary tasks table. Neither screen is written by hand. Both are screens, published when the API first starts.
  • Configure UI for Super Admin, in the settings menu at the top right:
    • Forms: every form in the app. Create a new form (a label people see and a key that names its table), design its form and list, publish (which creates or changes its table), open it, and add it to the side rail.
    • Menu: rename, reorder and hide the side rail’s items.
  • Multi-tenancy, if you asked for it. Every row is stamped with the company (or workspace) it was made in, every read sees only its own rows, membership is checked on every request, and choosing one is the first screen after sign-in.
  • A welcome screen that checks it can reach everything and tells you which files to change first.
  • READMEs at the root and in api/ and ui/, and a CLAUDE.md.

api/

Path What
development.env every setting: ports, the encrypted connection, REDIS_PREFIX, secrets, the super admin, email
env.required.js the settings that must be present. The API refuses to start and names any that are missing
app.js the Express app: the auth gate and the routes
routes/index.js this app’s own routes, with every data address under /api
routes/access.js who may design and publish forms (Super Admin)
screens/ the forms the app ships with: for each form, its spec, list and edit screens, server hooks and model, registered in screens/index.js
migrations/ hand-written SQL for tables that no form owns
migrations-auth/ this app’s rows in the sign-in database: menus (by key) and permissions
models/ models for hand-written tables

ui/

Path What
.env the dev port and where the sign-in service and the API are. vite.config.js reads it
vite.config.js proxies /auth/api to sign-in, and /api, /health and /whoami to the API. A new data prefix must be added here too
src/main.jsx ThemeProvider (required) and AccessProvider
src/App.jsx routes, the side rail (drawerItems) and the settings menu (settingsOverrides), by key, never by label
src/menu.js every menu key the app uses. Forms added to the menu are form:<key>
src/api/factory.js the calls to the API’s forms routes
src/pages/Tasks.jsx, EditTask.jsx the sample list, and the task form’s front-end hooks (TaskHooks extends FactoryHooks)
src/pages/ConfigureUI.jsx Configure UI: Forms.jsx and MenuSettings.jsx tabs, with FormDesigner.jsx for designing one form
src/pages/FormRecords.jsx opens any published form. A form added to the menu links here

Start it

The installer has already run npm install in both halves. If you gave the database connection, it also ran npm run setup, which creates both databases, the sign-in tables and the permissions.

cd myapp/api && npm run start-auth   # sign-in
cd myapp/api && npm run start-api    # your API; publishes the sample screens on first start
cd myapp/ui  && npm run dev          # the app

Open the UI and sign in with the account you named. As Super Admin, you see Configure UI in the settings menu.

xeplr new myapp --no-install only writes the files. After that, run npm install in api and ui, and npm run setup in api. If setup stops partway (Postgres not running, say), the installer names the step and the command to run again.

Commands in a generated project

Where Command Does
api/ npm run setup creates both databases, the sign-in tables, permissions and menus
api/ npm run start-auth starts the sign-in service. It refuses to start without Redis or REDIS_PREFIX, and its banner says whether email works
api/ npm run start-api starts the API. It runs migrations/ and publishes screens that were never published
api/ npm run migrate:auth applies new migrations-auth/*.sql files (a new menu row, say). Restart sign-in afterwards
api/ npm run db:encrypt redoes the encrypted database connection
api/ npm run check-env checks that every required setting is present
api/ npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages generates a form’s screens, server hooks and model from its spec
api/ npx xeplr-factory validate <screen>.json checks a screen document
ui/ npm run dev runs the app
ui/ npm run build builds into dist/

Settings to know

Setting
REDIS_PREFIX required and unique to this app, set to <name>:
<NAME>_CONNECTION, AUTH_DB_CONNECTION_INFO_ENCRYPTED the database login, encrypted. Redo it with npm run db:encrypt
DB_API, AUTH_DB_NAME the two database names, never guessed
AUTH_SUPER_ADMIN_EMAIL, AUTH_SUPER_ADMIN_PASSWORD the first account, created once when the sign-in database is first set up
AUTH_ACTIVATION_URL, AUTH_INVITE_URL full addresses of UI pages. The sign-in service appends ?token=…
EMAIL_PROVIDER and its settings optional. Without them sign-in works, but activation, invite and reset links cannot be sent

Changing the database connection later

When a password rotates, the database moves, or the first answer was wrong, run this from the project’s api folder:

npm run db:encrypt

It reads the encryption key from your settings, asks for the database details and rewrites every connection setting. Outside a project (before npm install, or from any other folder), npx @xeplr/cli encrypt asks for the key too and prints the result instead of writing it.

Create a new UI

A new UI is a form: a list, plus an add/edit form that the list opens in a popup, saved in its own table. There are two ways to make one.

Without code

Go to Configure UI → Forms → New form → design → Publish → Add to menu.

A form made this way has no page, hooks or model files. Add them with the steps below when it needs them.

With Claude

Open Claude in the project folder and give it this prompt, which also appears beside the sample with a Copy button:

Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Notes (long text). Show Name, Region and Area in the list. Follow “Create a new UI” in CLAUDE.md.

Change the name and the fields. You can add behaviour in the same message, such as “a Mark active button on each row”, “Region is required when Area is over 100” or “store tags as an array”.

Claude follows CLAUDE.md. <form> is singular (farming_department) and the page name is plural (FarmingDepartments).

  1. The spec: api/screens/<form>/<form>.entity.json, copied from task.entity.json.
  2. Screens and server files, from api/:
    npx xeplr-factory screens screens/<form>/<form>.entity.json -o screens/<form> --no-pages
    npx xeplr-factory validate screens/<form>/<form>-edit.screen.json
    npx xeplr-factory validate screens/<form>/<form>-list.screen.json
    This writes <form>-list.screen.json, <form>-edit.screen.json, <form>.hooks.js and <form>.model.js.
  3. Registration in api/screens/index.js: both screens in documents (edit before list), the hooks under <form>_edit, the model in models.
  4. The form’s page ui/src/pages/Edit<Form>.jsx, copied from EditTask.jsx, holding the front-end hooks. Every method calls super.
  5. The list’s page ui/src/pages/<Forms>.jsx, copied from Tasks.jsx.
  6. The route and side-rail item in ui/src/App.jsx. The drawerItems entry is by key and has an icon.
  7. The menu row api/migrations-auth/000N_<forms>_menu.sql, whose name is the key spelled exactly as in drawerItems. Then run npm run migrate:auth and restart the sign-in service.
  8. A restart of the API. It publishes the new screens, creates the table, and logs [api] published screens: <form>_edit, <form>_list.
  9. A check: open the page, click New and fill in the form. The popup says “All changes saved” and the row is in the table.

After that, change only what the request asks for:

The request says… Change
a field, label, rule, dropdown, layout, colour the screen JSON, then validate and publish in Configure UI → Forms (or restart the API if it was never published)
hide or filter rows on screen, an extra button on each row, fill in a value before saving the front-end hooks in ui/src/pages/Edit<Form>.jsx
must, only if, check against, email when, only managers see the server hooks in api/screens/<form>/<form>.hooks.js
store as, convert, comma-separated or array, work out X from Y the server model in api/screens/<form>/<form>.model.js
a query in your own server code factory.table('<forms table>')

How screens, hooks and models work is covered in Forms and screens.

Rules the generated app follows

Rule Why
Records live in real tables, one column per field, never JSON. Only a screen’s design is JSON (factory_screens) select * from tasks and plain-SQL reports must work
Every name has a key and a label. Code uses keys (form keys, menu keys). People read labels, which are renamed in Configure UI renaming what people see never breaks code
REDIS_PREFIX is required and unique per app two apps on one Redis under the shared default serve each other’s sessions, menus and API permissions. Sign-in refuses to start without it, or with xeplr:
Designing, creating forms and publishing are Super Admin only (api/routes/access.js) publishing changes database tables. Every other screen route checks the permission catalog
The browser is not a security boundary anything that must hold goes in server hooks
Every data address is under /api a page path and a data path must never collide
No guessed database names or connections a wrong-but-present value starts cleanly and reads as empty data
Email is checked at start and is never fatal. The sign-in banner shows email ✓ … or ✗ NOT WORKING — reason sign-in works without email, but links cannot be sent until EMAIL_PROVIDER is set
Never rename a field that is already a column it would drop the column and its data. Add a new field instead
A published screen’s design lives in the database editing its .screen.json afterwards changes nothing. Publish the change in Configure UI → Forms
authFetch returns the parsed body, not a Response there is no .json() to call

Last updated 16 September 2026