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).
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, permissions, generated capabilities
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)
scripts/capabilities.js regenerates the capability 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
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.4" },
"license": "MIT",
"private": true
}
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,typeis fine,default,new,function, …). The typed client is generated in several languages, and a keyword breaks the generated code. Reach for a synonym (public→shared).
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 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 (also runs on the install event below).
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: seed defaults when the app is installed.
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;
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 (the manifest's capabilities). Generate the client with the App SDK CLI:
npx appsdk generate \
--schema ./schema.json \
--manifest ./manifest.json \
--out ./src \
--target js
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 overwrites manifest.capabilities wholesale on every run, so it's the single source of truth for the capability list — never hand-edit the array.
#!/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 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' } } } },
}),
];
manifest.capabilities = 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 with the revenexx CLI
One command takes the App from this directory to a live capability. Sign in with your tenant slug and API key, then deploy.
# Sign in with your API key and tenant. The CLI saves the session to
# ~/.revenexx, so later commands run scoped to your tenant.
revenexx login --token "$REVENEXX_API_KEY" --tenant "$REVENEXX_TENANT"
# From the app directory (the one with manifest.json): register the App if
# it's new, upload the code, build it, publish the version, install it.
revenexx deploy app
revenexx deploy app reads manifest.json, schema.json, and the capabilities you generated. On a first run it registers the App; on later runs it deploys a new version, so bump the manifest version first. The build applies your schema and registers your capabilities; the install step is what writes your gateway routes, so a normal deploy has to install.
Flags worth knowing: --no-publish and --no-install stop before the Marketplace publish and the tenant install, --timeout <seconds> sets how long to wait for the platform build (default 600), and --runtime (default node-25) and --specification apply only to a first-time registration. Run revenexx deploy app --help for the full list.
revenexx deploy app on an App wired to a Git repository. It uploads a tarball, and the active deployment loses its commit reference. Git-connected Apps deploy by pushing to the connected branch.8. Verify against the gateway
A deployed app answers at the API gateway, https://api.revenexx.com, scoped to your tenant. Confirm your capability is live by calling it directly. Seed the defaults first, then list:
# Seed the default warehouse.
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 '{}'
# List locations — your app, live.
curl -s https://api.revenexx.com/v1/locations \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT"
Set REVENEXX_API_KEY to your machine-to-machine key and REVENEXX_TENANT to your tenant slug. The first call seeds the main warehouse; the second returns it. You can also point the CLI's client at the gateway to run ad-hoc calls:
revenexx client --endpoint https://api.revenexx.com --key "$REVENEXX_API_KEY"
Send {} as the body on any POST to a capability that declares a request schema — the gateway validates the body and rejects an empty one.
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 — the new column is added but the old one isn't dropped. Your authored capability contract is unaffected; clean up the stale column in a later release.
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 with the CLI — the full deploy-and-verify loop, plus caching for read-heavy capabilities.
- App SDK — the data client and router reference.