Custom Apps

What It Is

Custom Apps let an implementer deploy a self-built front-end — for example a React or TypeScript single-page application — directly onto a tenant. The platform hosts the app on the tenant's own domain, tracks its versions, and can run small server-side request handlers that the app calls to read and write tenant data.

It is the extension surface to reach for when the built-in Views and Canvases are not enough and you need a fully bespoke interface, while still relying on the platform for hosting, authentication, and data access.

Why It Matters

  • Bespoke interfaces. Build any experience your front-end toolchain can produce, beyond what configured views and canvases express.
  • No separate hosting. The app is served from the tenant itself, so there is no extra deployment target to operate.
  • Same-origin authentication. Because the app runs on the tenant's own domain, it reuses the signed-in session automatically — the browser never has to hold an API key or token.
  • A safe server-side boundary. Sensitive logic and credentials can live in server-side handlers the app calls, so a public-facing app can expose exactly the operations it needs and nothing more.

Mental Model

Think of a Custom App as a small, versioned deployment with two halves and a contract between them:

  • An App is a named entry with a URL slug. It owns a history of versions; exactly one version is active at a time.
  • A version is an uploaded bundle (a .zip) containing the built front-end, optional server-side handlers, and a manifest describing how they connect.
  • The active version is served at /app/<slug>/.
  • The app's own server-side routes live under /app/<slug>/api/<route>. These run on the platform, not in the browser.

The key idea: the browser only ever talks to the app's own routes and static files. Anything privileged — reading restricted data, writing records, holding a secret — happens inside a server-side handler that the app calls. This is what makes a public-facing app safe.

The Bundle

A bundle is a .zip with three parts:

bundle.zip
├── public/             # the built front-end, served at /app/<slug>/
│   └── index.html
├── server/             # optional server-side request handlers
│   └── hello.php
└── jetstack.app.json   # manifest: connects routes to handlers
  • public/ is the web root. Whatever your build tool emits (HTML, JavaScript, CSS, assets) goes here. index.html is the entry point.
  • server/ holds the app's server-side handlers (see Server-Side Handlers). Omit it entirely for a purely static app.
  • jetstack.app.json is the manifest.

When you build a single-page app, set your build tool's base path to /app/<slug>/ so asset links resolve correctly under the app's URL.

The Manifest

jetstack.app.json declares metadata and the app's server-side routes:

{
  "name": "Hello World",
  "description": "A minimal starter app.",
  "runtime": "php",
  "routes": {
    "GET /hello": { "handler": "server/hello.php", "auth": "public" }
  }
}
  • name / description — shown in the admin and in the templates store.
  • runtime — the handler runtime for this bundle.
  • routes — a map from a request signature to a handler. Each key is "<METHOD> /<route>" (for example "POST /comments"), matched against requests to /app/<slug>/api/<route>. Each value names the handler file (relative to the bundle root) and an auth level:
    • public — anyone may call the route, including anonymous visitors.
    • user — only a signed-in user may call it; anonymous requests are rejected.
    • internal — not reachable over HTTP at all (returns 404); the route runs only when invoked by the platform — from the scheduler or an async continuation. Use it for code meant to run on a timer or callback, never directly from a browser.

A static-only app simply omits routes (and server/).

The manifest is read from the deployed jetstack.app.json file on every request — it is the single source of truth, so editing it (or redeploying) takes effect immediately, including the per-route auth level. It is not copied into a database.

How Apps Are Served

The active version is served at /app/<slug>/:

  • Static files are served from public/.
  • Client-side routing is supported. A request for a path that has no matching file falls back to index.html, so deep links into a single-page app work on refresh.
  • The newest activation is served immediately. Activating or rolling back a version takes effect on the next page load; content-hashed assets are cached aggressively, while the entry page is always revalidated.
  • A runtime bootstrap is provided. The served entry page exposes a small window.__JETSTACK__ object to the app's JavaScript:
    • apiBase — the base URL for the app's own routes (/app/<slug>/api).
    • appSlug — the app's slug.
    • csrfToken — a token to send when calling the platform's REST API directly as a signed-in user.
    • user — the signed-in user (id and roles), or null for an anonymous visitor.

Use window.__JETSTACK__.apiBase to call your server-side routes, and user to adapt the interface to whoever is viewing it. The page also receives a default effects runtime on the same object (see Front-End Effects).

Server-Side Handlers

A server-side handler is a small script in server/ that runs on the platform whenever its route is called. It is the app's "thin backend": the place to perform privileged operations and keep credentials away from the browser.

  • What a handler can do. Inside a handler, the platform Internal API is available as $jetstack. Through it the handler reads and writes records, looks up secrets, sends e-mail, calls outbound services, and more.
  • The incoming request is exposed as first-class variables for convenience:
    • $query — the URL query parameters, e.g. a request to /app/<slug>/api/items?page=2&q=red gives $query['page'] and $query['q'].
    • $post — the array of POST fields.
    • $body — the parsed JSON request body (an [] when the body is not JSON), handy for POST/PUT routes: $json['title'].
    • $request — the full request for anything else: ['method', 'slug', 'route', 'headers', 'query', 'body', 'json'] (body is the raw string).
  • How a handler responds. It returns its response by calling $jetstack->sendJson($data, $httpCode). The app's front-end reads that response.
  • UI feedback and caller checks. A handler can push UI back to the app — $jetstack->flashMessage(), $jetstack->showBanner(), $jetstack->runJavaScript() (see Front-End Effects) — and inspect the caller — $jetstack->getCurrentUser(), $jetstack->userHasRole(<id or system_name>), and $jetstack->userCan(<type>, <action>[, <property>]).

A minimal handler:

<?php
// server/hello.php — handles "GET /hello"
$jetstack->sendJson(['message' => 'Hello from the server side']);

Reading data. Query the tenant database with $jetstack->db->query($sql, $params) (the {type.property} shortcode syntax and parameter binding are documented in the Internal API reference). For a JSON response the simplest form is plain associative arrays via the public ->resultSet:

$rows = [];
foreach ($jetstack->db->query(
    'SELECT {invoice.id!} AS id, {invoice.total!} AS total
     FROM {invoice}
     WHERE {invoice}.is_deleted = 0 AND {invoice.status!} = ?',
    [$query['status'] ?? 'open']
)->resultSet->fetchAll() as $row) {
    $rows[] = (array) $row;          // ['id' => …, 'total' => …]
}
$jetstack->sendJson(['invoices' => $rows]);

Avoid (array) $row on the objects returned by fetchAllRows() — those are smart-row wrappers whose columns are read as $row->alias, not by array-casting. See Reading the result in the Internal API reference for all three row forms.

  • Handlers run in a secure sandbox. A handler has access to the Internal API and a curated set of safe functions only. It cannot reach the filesystem, run shell commands, or load arbitrary code. This is what lets you accept a deployed bundle and still trust the boundary: the handler can only do what the Internal API permits.

Because the handler decides exactly which operation to perform, a public route exposes only that single operation — not general data access. For example, a public "submit comment" route can create a comment record with a pending status for later moderation, without exposing the comments collection to the world.

Sandbox constraints

Handler code runs in the same sandbox as automation and canvas PHP, so the same rules apply. The constraint that surprises people most:

  • No top-level named functions. A function name() { … } declaration at the top level of a handler is rejected by the sandbox. Define helpers as closures assigned to a variable, or as methods on a class instead:
<?php
// ✗ rejected — top-level function declaration
// function total($rows) { … }

// ✓ closure
$total = function (array $rows): int {
    return array_sum(array_column($rows, 'amount'));
};

// ✓ class with a method
class Report {
    public function total(array $rows): int {
        return array_sum(array_column($rows, 'amount'));
    }
}

$jetstack->sendJson(['total' => $total($rows)]);

Other limits to keep in mind:

  • Functions are whitelisted. Common helpers are available (string, array, preg_*, json_*, date/time, math, curl_*, hashing, etc.), but arbitrary or dangerous functions are not — exec, system, eval, fopen, file_get_contents, file_put_contents, mail, header, and similar are blocked. Reach for the Internal API instead ($jetstack->fetch(), $jetstack->sendEmail(), $jetstack->query(), the file helpers, …).
  • No include/require of arbitrary paths. Use $jetstack->include('name') to pull in another file from the tenant's local space.
  • A curated class allowlist. Built-ins like DateTime, Closure, stdClass, exceptions, and the Jetstack/Soopio and Nette utility classes are available; reflection and direct file objects are not. Defining your own classes inside the handler is allowed.
  • Reach data and platform features through $jetstack, never by touching the engine — $jetstack->getPresenter() and the like are off-limits.

Scheduled Tasks

A handler does not have to be triggered by a browser request — it can run on a schedule (delayed, or recurring). Declare the route "auth": "internal" so it runs only from the scheduler (it returns 404 over HTTP), then schedule it with $jetstack->tasks:

"routes": {
  "POST /tasks/send-digest": { "handler": "server/tasks/digest.php", "auth": "internal" }
}
// from any handler — schedule the internal route to run every day, a minute from now:
$jetstack->tasks->schedule('tasks/send-digest', ['segment' => 'weekly-active'], [
    'at'    => '+1 minute',
    'every' => '+1 day',
]);

A scheduled handler runs in the sandbox as the user who scheduled it (ACL active), and receives the stored $args plus a $task meta array — not the HTTP $request/$query/$post/$body, since there is no request:

<?php // server/tasks/digest.php
$segment = $args['segment'] ?? 'all';
$jetstack->utils->log('digest run for ' . $segment . ' (task ' . $task['systemName'] . ')');
// …do the work; call any Internal API, $jetstack->include() a shared lib, run automations, etc.

For a one-off snippet you do not want to add as a route, schedule inline code instead with $jetstack->tasks->scheduleCode('<php>', $args, $options). Either way, tasks run on the platform cron (every ~5 minutes) inline on that tick — keep them short and dispatch heavy work asynchronously (e.g. via $jetstack->flow->asyncWaitpoint). See $api->tasks for the full options and management methods (cancel, skip, setLogging, list).

Front-End Effects

A handler can ask the front-end to show feedback — a flash toast, a banner, a snippet of JavaScript, or a debug dump — through the Internal API:

  • $jetstack->flashMessage($message, $type) — a transient toast ($type: success, warning, danger, info, primary, secondary, or default).
  • $jetstack->showBanner($message, $type, $publicId) — a persistent banner.
  • $jetstack->runJavaScript($code) — a snippet of JavaScript to run in the page.
  • $jetstack->dump($value, $title) — a debug dump rendered in a docked panel. Recorded only for superusers, so dumps never reach a regular or anonymous visitor — handy while developing without leaking server state in production.

These effects ride back in the response under a reserved __jetstack key, alongside the handler's own data:

{
  "message": "Saved.",
  "__jetstack": {
    "flashes": [ { "message": "Saved.", "type": "success" } ],
    "banners": [ { "message": "Heads up.", "type": "info", "publicId": null } ],
    "scripts": [ "console.log('done')" ],
    "dumps":   [ { "title": "order", "createdAt": "14:21:09", "html": "…" } ]
  }
}

The platform injects a small default runtime into every served app page that applies these automatically — your front-end gets working flashes, banners, scripts, and dumps with no client code. The runtime renders them itself, and uses the richer platform widgets when the platform runtime is also loaded.

Customizing or disabling the handlers

The runtime exposes four handlers on window.__JETSTACK__.handlersflash, banner, script, and dump. Replace any with your own function, or set it to null to disable it:

// Render flashes with your own toast component (f = { message, type })
window.__JETSTACK__.handlers.flash = function (f) {
  myToast(f.message, f.type);
};

// Render banners your way (b = { message, type, publicId })
window.__JETSTACK__.handlers.banner = function (b) {
  myBanner(b.message, b.type);
};

// Ignore server-sent scripts entirely
window.__JETSTACK__.handlers.script = null;

// Route dumps into your own panel (d = { title, createdAt, html })
window.__JETSTACK__.handlers.dump = function (d) {
  myDumpPanel(d.title, d.html);
};

The runtime reads the handlers at apply time, so overriding them anywhere during your app's startup is enough.

Applying effects yourself

By default the runtime auto-applies effects from same-origin JSON responses. To take full control, opt out and call the applier when you choose:

window.__JETSTACK__.autoApply = false;

const res = await fetch(window.__JETSTACK__.apiBase + '/save', { method: 'POST', body });
const data = await res.json();
// ... use data.message etc. ...
window.__JETSTACK__.applyEffects(data.__jetstack);

Access And Visibility

Access works at two levels that combine.

App-level visibility is set on the app's Access settings and governs the whole app — its page and its routes:

  • Public — anyone, including anonymous visitors.
  • Any signed-in user — a session is required.
  • Specific roles — only users holding one of the chosen roles (superusers and tenant admins always pass).

This is enforced on the direct /app/<slug>/ URL as much as on the routes, so opening the app URL cannot bypass it. If you embed the app in a module restricted to certain roles, set the same roles on the app so the direct URL enforces the same visibility.

Per-route auth in the manifest refines access within an app: a public route admits anyone the app-level policy allows, while a user route additionally requires a signed-in user. So a public app can still keep individual routes signed-in-only.

Inside a handler you can branch on the caller and reject a request:

if (!$jetstack->userCan('comment', 'create')) {
    $jetstack->sendJson(['error' => 'Not allowed'], 403);
    return;
}

Signed-in users are recognized through the existing session cookie. Beyond the app's own routes, the front-end may also call the platform REST API directly — scoped to the user's real role — by sending the csrfToken from window.__JETSTACK__. Use this for in-app administrative screens; keep public-facing behavior behind handlers.

A site that starts public and lets visitors sign in is the natural fit: make the app Public, expose read and submit operations as public handlers, and reveal more once window.__JETSTACK__.user is present.

Deploying And Versioning

Custom Apps are managed from the Custom apps screen, available to users with the deployApps permission. The lifecycle mirrors Version Management:

  • Create an app by giving it a slug, name, and description.
  • Set access on the app's Access settings — Public, any signed-in user, or specific roles.
  • Deploy a version by uploading a bundle .zip. Each upload becomes a new, inactive version.
  • Activate a version to make it the one served at /app/<slug>/.
  • Roll back by activating an earlier version — previous bundles are retained until you delete them.
  • Delete a version or an entire app when it is no longer needed.

App Templates

The Custom apps screen includes an App templates store: downloadable starter bundles you can unzip, build your front-end into, and deploy. The starter demonstrates the bundle layout, a manifest, and a working server-side route. Use it as the skeleton for a new app rather than assembling the structure by hand.

Embedding In A Module

A Custom App does not have to occupy a whole page. A Module can render a deployed app inline by setting its Embedded app property to the app's slug. The app then appears framed within the module's content area, sharing the signed-in session like any other in-app surface — useful for placing a bespoke tool directly inside the navigation alongside configured views.

Typical Uses

  • public portals or micro-sites driven by tenant data
  • bespoke dashboards, wizards, or configurators that go beyond standard views
  • customer-facing forms whose submissions are validated and moderated server-side
  • embedded specialist tools surfaced inside a module

Best Practices

  • Treat deployApps as privileged. A deployed app runs on the tenant's own domain with the viewer's session; grant deployment only to trusted implementers.
  • Put every privileged action behind a handler. Do not rely on the front-end to enforce rules — expose narrow public routes that perform exactly one operation each.
  • Keep secrets in handlers. Read credentials from the secret store inside server-side code; never embed them in the bundle or the front-end.
  • Set the build base path to /app/<slug>/ and rely on content-hashed asset names so new versions are picked up cleanly.
  • Version deliberately. Activate intentionally and keep a known-good version available for rollback.
  • Scope user routes for anything that must require sign-in, and check the current user inside the handler as well.