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
taskstable. 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/andui/, and aCLAUDE.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).
- The spec:
api/screens/<form>/<form>.entity.json, copied fromtask.entity.json. - Screens and server files, from
api/:
This writesnpx 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<form>-list.screen.json,<form>-edit.screen.json,<form>.hooks.jsand<form>.model.js. - Registration in
api/screens/index.js: both screens indocuments(edit before list), the hooks under<form>_edit, the model inmodels. - The form’s page
ui/src/pages/Edit<Form>.jsx, copied fromEditTask.jsx, holding the front-end hooks. Every method callssuper. - The list’s page
ui/src/pages/<Forms>.jsx, copied fromTasks.jsx. - The route and side-rail item in
ui/src/App.jsx. ThedrawerItemsentry is by key and has an icon. - The menu row
api/migrations-auth/000N_<forms>_menu.sql, whosenameis the key spelled exactly as indrawerItems. Then runnpm run migrate:authand restart the sign-in service. - A restart of the API. It publishes the new screens, creates the table,
and logs
[api] published screens: <form>_edit, <form>_list. - 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