Build your first App
This tutorial builds a complete revenexx App from an empty directory: a Locations app with one entity, full CRUD served through the API gateway, a seeded default, and a custom batch endpoint. You test it on your laptop, deploy it with the revenexx CLI, and confirm it answers a real request at https://api.revenexx.com.
This is for backend developers. Before you start, install the App SDK and the CLI, and have a tenant slug and an X-Revenexx-Api-Key ready (see Prerequisites). A development key with full access (*) is simplest; calling the finished App needs locations.*.
Quick start
If you just want a working app, scaffold it in one command. revenexx create app stamps the same files this tutorial builds by hand — manifest, schema, capabilities, a typed data client, and a test suite — npm test-green out of the box:
revenexx create app locations --entity locations
cd locations
npm install && npm test
revenexx deploy app
See Scaffolding for everything create app stamps and the flags it takes. The rest of this tutorial builds the same app by hand, so you understand each file the scaffold generates.
An App is a directory of declarative JSON plus a Node function. The platform reads the JSON to provision your tables and your API; the function holds whatever custom logic you add. The files you'll create:
locations/
manifest.json identity and permissions
schema.json your entities → platform tables
cockpit.json the admin UI
billing.json marketplace listing
package.json one dependency: @revenexx/app-sdk
src/main.js the function entrypoint
src/db.generated.js the typed data client (generated — never hand-edit)
manifest.capabilities.json the capability list (generated — never hand-edit)
scripts/capabilities.js regenerates that list from your schema + routes
test/locations.test.js node --test against an in-memory data store
1. Create the project
Make the directory and a package.json with the one dependency you need.
mkdir locations && cd locations
mkdir src scripts test
npm init -y
npm install @revenexx/app-sdk@^0.8.0
Set the entrypoint and a test script so node --test runs your suite.
{
"name": "locations",
"version": "0.1.0",
"description": "Stock locations — warehouses, stores, dropship and virtual.",
"main": "src/main.js",
"scripts": {
"test": "node --test",
"capabilities": "node scripts/capabilities.js"
},
"engines": { "node": ">=22" },
"dependencies": { "@revenexx/app-sdk": "^0.8.0" },
"license": "MIT",
"private": true
}
The tutorials pin ^0.8.0, the range the event guide needs; revenexx create app may pin an older range, and the code here is the same on either (as of 2026-10). The local requirement of Node 22 or newer comes from the scaffolded engines field, not from the SDK.
2. Declare the entity in schema.json
schema.json is the source of truth for your tables. The platform reads it and provisions a table per entity, scoping every row to the tenant automatically — you never declare a tenant column, and you never write a migration.
Declare one entity, locations. Each column has a type; add notNull, default, and check where the data demands it. Use indexes for the lookups you'll do — here, a unique location code per tenant.
{
"$schema": "https://schemas.revenexx.com/schema.schema.json",
"entities": {
"locations": {
"columns": {
"id": { "type": "uuid", "pk": true, "default": "gen_random_uuid()" },
"code": { "type": "text", "notNull": true, "check": "length(code) > 0" },
"name": { "type": "text", "notNull": true, "check": "length(name) > 0" },
"labels": { "type": "jsonb" },
"type": { "type": "text", "notNull": true, "default": "'warehouse'", "check": "type in ('warehouse', 'store', 'dropship', 'virtual')" },
"priority": { "type": "integer", "notNull": true, "default": "0" },
"enabled": { "type": "boolean", "notNull": true, "default": "true" },
"address": { "type": "jsonb" },
"metadata": { "type": "jsonb" },
"created_at": { "type": "timestamptz", "notNull": true, "default": "now()" },
"updated_at": { "type": "timestamptz", "notNull": true, "default": "now()" }
},
"indexes": [
{ "columns": ["tenant_id", "code"], "unique": true },
{ "columns": ["enabled"] }
]
}
}
}
Two rules that save you a failed build:
- Never declare a
tenant_idcolumn. The platform injects it and scopes every query to the tenant for you. For per-tenant uniqueness, puttenant_idfirst in a composite index, as above. - Never name a column after a reserved word in a common language (
public,class,default,new,function, …). The typed client is generated in several languages, and a keyword breaks the generated code. Reach for a synonym (public→shared). Thetypecolumn above is accepted as it is.
3. Declare identity and permissions in manifest.json
The manifest names the app and grants it access to each entity. Keep it small — the long capabilities array is generated in the next step, into a sibling file. Dependency keys, when you have them, are vendor/app (you have none yet).
{
"$schema": "https://schemas.revenexx.com/manifest.schema.json",
"name": "locations",
"vendor": "acme",
"version": "0.1.0",
"title": "Locations",
"type": "public",
"description": "Stock locations — warehouses, stores, dropship and virtual.",
"permissions": [
{ "entity": "locations", "access": ["read", "create", "update", "delete"] }
],
"events": {
"listens": ["app.installed", "app.uninstalled"]
}
}
The two other declarative files are short. billing.json is the marketplace listing — included apps bundle with a package:
{
"$schema": "https://schemas.revenexx.com/billing.schema.json",
"type": "included",
"support": { "email": "support@acme.example", "url": "https://acme.example/support" },
"categories": ["commerce"],
"available_countries": ["*"]
}
cockpit.json declares the admin UI. You can start with a single list view over the entity and grow it later — see Cockpit integration for the full surface.
4. Write the function
src/main.js is the entrypoint the platform invokes. The App SDK router does the HTTP work: createApp makes the router, mountCrud exposes an entity over standard CRUD paths, and createDb gives you the typed data client over your tables.
The exported handler has a fixed shape. It seeds defaults through an explicit route (and, as a convenience, on the install event), rejects requests that arrive without a tenant identity, and otherwise hands off to the app.
'use strict';
const { createApp, mountCrud } = require('@revenexx/app-sdk/router');
const { createDb, ENTITIES } = require('./db.generated');
// Every tenant starts with one main warehouse. Seeding is idempotent (by code).
const DEFAULT_LOCATIONS = [
{ code: 'main', name: 'Main warehouse', labels: { en: 'Main warehouse' }, type: 'warehouse', priority: 0, enabled: true },
];
async function ensureDefaults(db) {
const created = [];
const existing = [];
for (const location of DEFAULT_LOCATIONS) {
const [found] = await db.locations.list({ where: { code: location.code }, limit: 1 });
if (found) existing.push(location.code);
else { await db.locations.create(location); created.push(location.code); }
}
return { created, existing };
}
function buildApp(db) {
const app = createApp({
name: 'locations',
healthExtra: () => ({ entities: Object.keys(ENTITIES) }),
});
mountCrud(app, db.locations, {
path: '/locations',
columns: ENTITIES.locations.columns,
});
// Seed the main warehouse on demand. This route is the reliable path;
// the install event below is a convenience on top of it.
app.post('/locations/defaults', async (c) => c.json(await ensureDefaults(db)));
return app;
}
module.exports = async (context) => {
const { req, res } = context;
const headers = req.headers || {};
const jwt = headers['x-revenexx-context'] ?? headers['X-Revenexx-Context'] ?? '';
const path = (String(req.path || '/').replace(/\/+$/, '')) || '/';
// Lifecycle: also seed when the install event arrives.
const trigger = headers['x-revenexx-trigger'] ?? '';
const event = headers['x-revenexx-event'] ?? '';
if (trigger === 'event' && jwt) {
const db = createDb({ adapter: 'runtime', context });
if (/(^|\.)installed$/.test(event)) {
return res.json({ event, ...(await ensureDefaults(db)) });
}
return res.json({ event, ignored: true });
}
// Every route except health needs the tenant identity header.
if (path !== '/' && !jwt) {
return res.json({ error: 'missing tenant identity' }, 503);
}
const db = createDb({ adapter: 'runtime', context });
return buildApp(db).handler()(context);
};
module.exports.buildApp = buildApp;
module.exports.ensureDefaults = ensureDefaults;
app.installed does not reliably fire on a Marketplace install, so an App that seeds only there reaches a merchant with an empty configuration. The idempotent /locations/defaults route lets you seed and prove it on your own tenant, and an App you publish should also ensure its defaults on first use. Both paths are idempotent, so both may land. See Publish on the Marketplace, step 6.mountCrud gives you, under /locations:
| Method | Path | Does |
|---|---|---|
GET | /locations | list (with limit, offset, order, and ?column=value filters) |
GET | /locations/{id} | read one |
POST | /locations | create |
PUT | /locations/{id} | update |
DELETE | /locations/{id} | delete |
Route paths are all-lowercase, and every route lives under the app's own prefix (/locations). That namespacing is how the gateway routes to your app.
5. Generate the typed client and the capabilities
Two things are generated from your declarations: the typed data client (src/db.generated.js) and the capability list (manifest.capabilities.json). Generate the client with the CLI:
revenexx apps generate \
--schema ./schema.json \
--manifest ./manifest.json \
--out ./src
This writes src/db.generated.js, exporting createDb and ENTITIES. Never edit it by hand — regenerate it whenever the schema changes. The client only exposes the operations your manifest grants: with read, you get list and get; create, update, and delete follow their grants.
Now the capabilities. The gateway only advertises — and the typed SDK only exposes — the capabilities your manifest declares. scripts/capabilities.js builds that list from your schema and routes. It writes the list to manifest.capabilities.json on every run and removes any inline capabilities key from manifest.json, so the file is the single source of truth for the capability list — never hand-edit it. The platform splices the file back into the manifest at deploy. Ship either the sibling file or an inline capabilities array, never both: the platform rejects a manifest that has both. revenexx apps capabilities --write writes the same file. This tutorial's script rewrites the whole file on every run. The script that revenexx create app scaffolds merges instead, and keeps hand-written entries it does not emit, so do not mix the two on one project.
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const schema = JSON.parse(fs.readFileSync(path.join(root, 'schema.json'), 'utf8'));
const manifestPath = path.join(root, 'manifest.json');
const capabilitiesPath = path.join(root, 'manifest.capabilities.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Map a schema column to a JSON-Schema property.
function columnSchema(col) {
const nullable = !col.pk && !col.notNull;
const wrap = (t, extra = {}) => ({ type: nullable ? [t, 'null'] : t, ...extra });
switch (col.type.replace(/\(.*\)$/, '')) {
case 'uuid': return wrap('string', { format: 'uuid' });
case 'boolean': return wrap('boolean');
case 'integer': return wrap('integer');
case 'timestamptz': return wrap('string', { format: 'date-time' });
case 'jsonb': return { type: nullable ? ['object', 'null'] : 'object' };
default: return wrap('string');
}
}
const LOCATION = {
title: 'Location',
type: 'object',
properties: Object.fromEntries(
Object.entries(schema.entities.locations.columns).map(([name, col]) => [name, columnSchema(col)]),
),
};
const PAGE = {
type: 'object',
properties: {
limit: { type: 'integer' }, offset: { type: 'integer' }, total: { type: 'integer' },
returned: { type: 'integer' }, hasMore: { type: 'boolean' },
},
};
const pageOf = (item) => ({ type: 'object', properties: { items: { type: 'array', items: item }, page: PAGE } });
// A paged list MUST declare these or every caller is stranded on page 1.
const qParam = (name, paramSchema, description) => ({ name, in: 'query', required: false, schema: paramSchema, description });
const PAGINATION_PARAMS = [
qParam('limit', { type: 'integer', minimum: 1, maximum: 200 }, 'Page size (default 50, max 200).'),
qParam('offset', { type: 'integer', minimum: 0 }, 'Row offset for pagination (default 0).'),
qParam('order', { type: 'string' }, "Sort as 'column.asc' | 'column.desc', e.g. 'created_at.desc'."),
];
const LOCATION_INPUT = {
title: 'LocationInput',
type: 'object',
required: ['code', 'name'],
properties: {
code: { type: 'string', minLength: 1 },
name: { type: 'string', minLength: 1 },
type: { type: 'string', enum: ['warehouse', 'store', 'dropship', 'virtual'] },
priority: { type: 'integer' },
enabled: { type: 'boolean' },
labels: { type: ['object', 'null'] },
address: { type: ['object', 'null'] },
metadata: { type: ['object', 'null'] },
},
};
const DELETED = { type: 'object', properties: { deleted: { type: 'boolean' }, id: { type: 'string' } } };
const def = (capability, summary, route, extra) => ({ type: 'define', capability, version: '1.0.0', summary, route, ...extra });
const capabilities = [
def('locations.list', 'List stock locations', { method: 'GET', path: '/locations' }, { parameters: PAGINATION_PARAMS, response: pageOf(LOCATION) }),
def('locations.get', 'Read one location', { method: 'GET', path: '/locations/{id}' }, { response: LOCATION }),
def('locations.create', 'Create a location', { method: 'POST', path: '/locations' }, { request: LOCATION_INPUT, response: LOCATION }),
def('locations.update', 'Update a location', { method: 'PUT', path: '/locations/{id}' }, { request: LOCATION_INPUT, response: LOCATION }),
def('locations.delete', 'Delete a location', { method: 'DELETE', path: '/locations/{id}' }, { response: DELETED }),
def('locations.defaults', 'Seed the main warehouse — idempotent', { method: 'POST', path: '/locations/defaults' }, {
response: { type: 'object', properties: { created: { type: 'array', items: { type: 'string' } }, existing: { type: 'array', items: { type: 'string' } } } },
}),
];
fs.writeFileSync(capabilitiesPath, `${JSON.stringify(capabilities, null, 2)}\n`);
delete manifest.capabilities;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`wrote ${capabilities.length} capabilities`);
Run it whenever you change the schema or the routes:
node scripts/capabilities.js
A capability summary is short — keep it under 255 characters. Longer prose goes in a description field. Declare pagination params only on paged lists, never on get/create/update/delete.
6. Test it locally
You test the real handler against an in-memory data store, so the data client behaves exactly as it will in production. Copy the harness below: it stands up a fake data backend keyed by table through globalThis.fetch, builds a tenant-identity token, and calls the exported handler.
'use strict';
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const handler = require('../src/main');
let tables = {};
// Minimal in-memory backend keyed by table, wired in via global fetch.
function fakeBackend(url, opts) {
const u = new URL(url);
const table = u.pathname.split('/').filter(Boolean).pop();
tables[table] ??= [];
const store = tables[table];
const method = opts.method || 'GET';
const body = opts.body ? JSON.parse(opts.body) : null;
const idEq = (u.searchParams.get('id') || '').replace(/^eq\./, '');
const ok = (rows, total) => ({
ok: true, status: 200,
headers: { get: (h) => (h.toLowerCase() === 'content-range' && total != null ? `0-${Math.max(rows.length - 1, 0)}/${total}` : null) },
text: async () => JSON.stringify(rows),
});
if (method === 'GET') {
let rows = store.slice();
for (const [k, v] of u.searchParams) {
if (['select', 'order', 'limit', 'offset'].includes(k)) continue;
if (v.startsWith('eq.')) rows = rows.filter((r) => String(r[k]) === v.slice(3));
}
return ok(rows, rows.length);
}
if (method === 'POST') {
const row = { id: crypto.randomUUID(), created_at: new Date().toISOString(), tenant_id: 'acme', ...body };
store.push(row);
return ok([row]);
}
if (method === 'PATCH') {
const i = store.findIndex((r) => r.id === idEq);
store[i] = { ...store[i], ...body };
return ok([store[i]]);
}
if (method === 'DELETE') {
tables[table] = store.filter((r) => r.id !== idEq);
return { ok: true, status: 204, headers: { get: () => null }, text: async () => '' };
}
return { ok: false, status: 500, headers: { get: () => null }, text: async () => 'unhandled' };
}
function jwt(claims) {
const b = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
return `${b({ alg: 'RS256' })}.${b(claims)}.sig`;
}
function call(method, path, { body = null, headers = {} } = {}) {
let captured;
const ctx = {
req: {
method, path, body,
headers: { 'x-revenexx-context': jwt({ sub: 'svc', tenant_id: 'acme' }), 'x-revenexx-tenant': 'acme', ...headers },
},
res: { json: (b, code = 200) => { captured = { body: b, code }; return captured; } },
log: () => {}, error: () => {},
};
return handler(ctx).then(() => captured);
}
beforeEach(() => {
tables = {};
globalThis.fetch = fakeBackend;
process.env.REVENEXX_DATA_ENDPOINT = 'https://api.revenexx.com/';
});
afterEach(() => { delete process.env.REVENEXX_DATA_ENDPOINT; });
test('health lists entities; defaults seed once', async () => {
const health = await call('GET', '/', { headers: { 'x-revenexx-context': '' } });
assert.deepEqual(health.body.entities, ['locations']);
assert.deepEqual((await call('POST', '/locations/defaults')).body.created, ['main']);
assert.deepEqual((await call('POST', '/locations/defaults')).body.created, []); // idempotent
});
test('create then read back a location', async () => {
const created = await call('POST', '/locations', { body: { code: 'berlin', name: 'Berlin store', type: 'store' } });
assert.equal(created.code, 201);
const read = await call('GET', `/locations/${created.body.id}`);
assert.equal(read.body.code, 'berlin');
});
test('install event seeds the main warehouse', async () => {
const r = await call('POST', '/', { headers: { 'x-revenexx-trigger': 'event', 'x-revenexx-event': 'apps.locations.installed' } });
assert.deepEqual(r.body.created, ['main']);
});
Run the suite:
npm test
Green here means the handler, the routes, and the data client all behave correctly before anything is deployed.
7. Deploy and verify
Sign in once, then deploy from the app directory (the one with manifest.json). login saves the key but not the tenant, so export the tenant slug as well.
export REVENEXX_API_KEY="rvxk_..."
export REVENEXX_TENANT="<TENANT_SLUG>"
revenexx login --token "$REVENEXX_API_KEY" --tenant "$REVENEXX_TENANT"
revenexx deploy app
A deployed App answers at the API gateway, https://api.revenexx.com, scoped to your tenant. Seed the default warehouse, then list:
curl -s -X POST https://api.revenexx.com/v1/locations/defaults \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "Content-Type: application/json" -d '{}'
curl -s https://api.revenexx.com/v1/locations \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT"
The first call seeds the main warehouse and the second returns it. Send {} as the body on any POST to a capability that declares a request schema, because the gateway rejects an empty one.
That is the whole loop. Deploy and verify an App owns the details: the flags, the Git-wired App warning, version bumps, the full smoke test and caching.
Troubleshooting
- Gateway returns 404 for
/locations. The build hasn't registered the capability yet, or the route prefix doesn't match the app name. Re-runnode scripts/capabilities.js, confirm every route lives under/locations, and create a new deployment. - A list never returns past the first page. The list capability is missing its pagination parameters. Add
PAGINATION_PARAMSto that capability'sdefineand regenerate. - "permission denied" on a write. The manifest doesn't grant that operation. Add it to
permissions, regenerate the client, and create a new deployment. - A column rename shows the old column too. Schema changes apply additively, so the old column is not dropped. See Deploy and verify an App.
Where to go next
- Add a data model — a second entity, a relationship, a custom action route with guards, and the typed data client in depth.
- Deploy and verify an App — the full deploy-and-verify loop, plus caching for read-heavy capabilities.
- App SDK — the data client and router reference.